* feat(core): add Black Forest Labs image adapter
Black Forest Labs (FLUX) generates asynchronously: a submit returns a polling_url, the task is polled until ready, and the image is downloaded from a result URL. BlackForestLabsImageAdapter runs that flow with fetch, sends input images as input_image, input_image_2, and so on, and maps a WIDTHxHEIGHT size to width and height.
A moderated task shows up only in the poll status. The adapter reports Request Moderated and Content Moderated as a non-retryable content_policy failure as soon as it sees them, so runImage moves to the next model instead of polling until the attempt deadline and retrying. Failed and lost tasks are retryable.
The API key goes only to the API origin and HTTPS hosts under bfl.ai, keyed requests refuse redirects, and the pre-signed image download carries no key. providerOptions follows the same rule as the other adapters: fields that carry the prompt or input images are rejected at construction, and width and height are defaults that request.size overrides.
Follow-up to #613.
* fix(core): report detected image media type and the BFL dimensions sent
The built-in image adapters declared a fixed media type, which was wrong whenever providerOptions chose another output format, for example output_format png on Black Forest Labs or jpeg on OpenAI. All four now report the type read from the returned bytes and fall back to the old default only when the bytes are not a recognized image.
The Black Forest Labs attempt parameters kept default width and height from providerOptions even when request.size replaced them in the request body. They now record the same width and height the request sent.
* fix(core): keep a submitted BFL task through transient poll failures
A transient failure while polling or downloading, such as a 429, a 5xx, or a network error, escaped the adapter as retryable, so runImage retried generate() and submitted another billable task while the first kept running. The Black Forest Labs adapter now retries those failures in place until the attempt deadline, honoring Retry-After. Non-retryable errors still throw at once, and so does a 4xx on the download, since an expired delivery link cannot recover without a new task.
The adapter contract now says what it meant: an adapter must not resubmit a generation internally, while retrying a status poll or download of an already-submitted task is allowed.
* fix(core): type rate-limited and timed-out image downloads
downloadImage turned every non-2xx status into api_error, so a 429 or 408 on a pre-signed result URL did not count as transient for the Black Forest Labs in-place retry, escaped to runImage, and resubmitted a billable task. A download 429 is now rate_limit with Retry-After and a 408 is timeout, so both are retried in place against the existing task.
* fix(core): scope BFL credentials and keep a submitted task final at the deadline
The Black Forest Labs adapter sent its key to any HTTPS host under bfl.ai. Behind a proxy that key belongs to the proxy, and a forwarded BFL polling URL would receive it. Sibling BFL hosts now qualify only when baseURL is itself a BFL host; otherwise the key goes to the configured origin alone.
A transient outage that lasted past the attempt deadline still led runImage to retry generate() and submit another billable task. Once a task is submitted, the adapter now reports a deadline as a non-retryable timeout, and runImage keeps a non-retryable adapter verdict at the deadline instead of replacing it with a retryable timeout, so the chain moves to the next model. A deadline before the submit completes stays retryable.
* fix(core): tell deadlines from cancellation and end BFL turns on permanent download failures
runImage now aborts an attempt's signal with a DOMException named TimeoutError when the deadline fires, the same convention as AbortSignal.timeout(), and with the caller's own reason on cancellation. The Black Forest Labs adapter turns only a deadline after the submit into a final timeout; a caller that cancels a direct generate() call gets its own abort reason back.
A permanent failure downloading a submitted task, such as an expired or missing result link, was still retryable, so runImage submitted and paid for another task. Once the task exists, a download failure that is not transient now ends that model's turn as non-retryable.
* fix(core): end a submitted BFL task's turn on any failure and keep its cost
Several failures after a Black Forest Labs task was submitted were still retryable: a Ready status without result.sample, an Error or Failed task, and a lost task. runImage retried them by submitting and paying for another task. Every failure after the submit now ends that model's turn as non-retryable.
The adapter's in-place retry of transient poll and download failures slept through any Retry-After, so a header above runImage's maxRetryAfterMs stalled the attempt until its deadline. runImage now passes its cap to adapters as ImageCallOptions.maxRetryAfterMs, and a longer wait ends the turn at once.
A failure after the submit also dropped the task ID and the cost BFL reported, because adapter params were only returned on success. ImageModelError now carries optional params, runImage copies them into the failed attempt record, and the BFL adapter attaches the task and its cost to every post-submit failure.
* fix(core): close the last BFL paths that resubmit a created task
Two failures after a successful Black Forest Labs submit still reached runImage as retryable and led it to submit and pay for another task: a submit response without polling_url, and a result download that answers 200 with bytes that are not an image. Both now end that model's turn as non-retryable invalid_output and carry the task ID and cost into the failed attempt record. The adapter checks the downloaded bytes itself instead of leaving it to runImage, whose retry of an invalid output would resubmit.
* feat(core): add OpenRouter image adapter
OpenRouter serves image generation and editing through its own /images endpoint, with input images in input_references, so OpenAIImageAdapter pointed at an OpenRouter baseURL cannot reach it. OpenRouterImageAdapter calls that endpoint with fetch, reads base64 output only, rejects a mask instead of dropping it, and honors egressPolicy.
Safety rejections arrive in two shapes. OpenRouter's own moderation answers 403 with reasons or flagged_input in error.metadata; a rejection by the upstream provider carries the provider's code in error.metadata.provider_code and its original body in error.metadata.raw. Both are classified as content_policy, reusing the OpenAI and Seedream rules for upstream codes. The shared content-policy rule now also receives the HTTP status and the full error object.
Follow-up to #610.
* fix(core): classify forwarded safety rejections before retryable 5xx
A gateway such as OpenRouter can forward an upstream safety rejection with a 5xx status. The shared image error classifier checked the 5xx branch first, so the adapter's content-policy rule never ran and the rejection was retried on the same model. The content-policy rule now runs before the timeout and 5xx branches; a 429 is still classified as a rate limit.
* fix(core): reject image providerOptions that replace adapter-set fields
providerOptions was spread last into the request body, so a key such as input_references, image, prompt, or model silently replaced what the adapter built from the request, and an edit could run on different images or none. The OpenAI, OpenRouter, and Seedream adapters now reject those keys with a TypeError at construction and spread the remaining options before their own fields.
* fix(core): keep size and watermark as image providerOptions defaults
The reserved-key check rejected size in every image adapter and watermark in Seedream, which removed a reasonable way to set a default output size. Only keys that carry the request itself (model, prompt, n, the input image fields, mask, and response_format) are rejected now. A size in providerOptions is a default that request.size overrides, and Seedream watermark in providerOptions again overrides the adapter default; the OpenAI multipart body sends a single size field.
* feat(core): add runImage and native image model adapters
Applications that generate or edit images had to call image APIs outside the framework and rebuild the same reliability code each time: retries, fallback across providers, error classification, and a record of every call. runImage() does this once, on an ImageModelAdapter interface that core owns, so no third-party SDK sits between OMA and the provider response it has to classify.
runImage() tries an ordered chain of models, retries retryable failures with jittered backoff that honors Retry-After, moves to the next model on anything else, reads the output format and size from the bytes, runs an optional validate check, and reports every provider call through result.attempts and onAttempt. It resolves with status failed instead of throwing when the chain runs out.
OpenAIImageAdapter (OpenAI Images API and compatible endpoints) and SeedreamImageAdapter (Seedream on Volcengine Ark) call the HTTP APIs with fetch, classify safety rejections as content_policy, fail rather than drop input they cannot use, request base64 output only, and honor egressPolicy. ImageModelError carries the normalized type and retryable flag, and isRetryableError() returns that flag.
Refs #469
* fix(core): keep runImage independent of attempt sinks and caller signals
onAttempt is no longer awaited, so a stalled or rejecting sink cannot hold up retries, cancellation, or the result. A caller that aborts while validate is pending now gets its abort instead of a successful image. Each attempt removes its abort listener from the caller's signal when it ends, so a signal reused across many calls no longer accumulates listeners.
The Anthropic adapter now sends adaptive thinking when thinking is enabled without budgetTokens, except on models from before adaptive thinking, which keep the 1024-token default budget; Claude Opus 4.7, Sonnet 5, and later reject budget_tokens with HTTP 400. The task profiler omits temperature only for Claude models that reject non-default sampling parameters, and the cost-tiered pipeline example drops the temperatures that broke its Opus 4.7 path. Built-in tool descriptions now state the sandbox boundary and match glob's path output, the coordinator prompt reused for synthesis no longer carries an unscoped JSON-only instruction, and the structured-output instruction states its requirement once.
The CLI binary is `oma` and the scaffolder is `create-oma-app`, but the README headline still reads "Open Multi-Agent". This leads with OMA and keeps the full name in the first descriptive sentence, so it stays tied to the `@open-multi-agent/core` scope a reader is about to install.
Both language versions of both READMEs move together, and `CONTRIBUTING.md` follows. The historical CHANGELOG entry and the example catalog schema title are deliberately left alone.
No identifiers change: the organization, repository, package names, CLI binary, and every URL stay exactly as they are. Documentation only, so `git diff --check` is the applicable check and it is clean.
The baseline section of docs/evaluation-ci.md enumerates every condition
that makes a regression comparison warn and skip, and it was missing the
one a threshold introduces: when it sets `minSamples` and either the
current or the baseline aggregate is short, the comparison does not run.
Record that condition and the consequence a gate author has to plan for,
namely that a short baseline now yields a warning where it previously
yielded a `regression` failure, so the guard loosens one check while it
tightens another.
Record the guard and `passSampleCount` in the changelog, set `minSamples`
on the runnable offline-regression example so the option appears where a
gate is actually configured, and credit the contributor.
Follow-up to #602.
Add an optional positive-integer `minSamples` guard to `GateThreshold` so a threshold only counts when the selected aggregate carries enough evidence. Score metrics compare it against `scoredCount`; `passRate` compares it against the new `ScorerAggregate.passSampleCount`, which records the pass denominator whenever `passRate` is emitted. Tag-scoped thresholds use the tag aggregate's own counts. A count below the minimum reports a new `insufficient_samples` failure carrying the observed count and the configured limit, and a report written before `passSampleCount` existed fails closed at zero rather than skipping the guard.
Baseline regression checks honor the same minimum. When either the current or the baseline aggregate is below it, the comparison is skipped with a warning instead of producing a regression number from a sample set the gate already called too small to judge.
Omitting `minSamples` preserves the previous behavior, so the report schema version is unchanged and no migration is required.
Fixes#573
Lead both root READMEs with the self-hosted runtime, durable approvals,
and verifiable run records instead of the coordinator. Keep the H1 and
logo block, replace only the tagline block, and add the no-telemetry,
no-hosted-control-plane paragraph after the badges.
Give durable approvals, the verifiable journal, and the governance floor
one section each with source, test files, and current case counts.
Demote multi-agent coordination to an "Optional coordinator" section,
keep "Built with OMA" with the three most visible entries and a
collapsed long tail, and drop the intro paragraph, the framework
comparison pointer, the listing call to action, and the separate
commercial support section. The Chinese README states the contact
details in text instead of a WeChat QR image, which is removed.
Sync the tagline block in the core package READMEs, set the same
description on the root and core package.json, and remove the "killer
feature" wording from the orchestrator header comments.
Two roles failed in 2026-09 with STRUCTURED_OUTPUT_VALIDATION_FAILED on
bracket mismatches deep inside long nested output, and the in-run
correction failed the same way. #599 moved the nested object to the end of
each schema so the model has no boundary to hold, which works around the
symptom without changing what the provider guarantees.
Set `response_format: { type: 'json_object' }` on the shared role config
through `extraBody`. DeepSeek then guarantees that the answer parses as
JSON, so the syntax failure class goes away at the source. A direct API
probe with the pre-#599 planner schema, thinking at max effort, and a tool
turn confirmed that DeepSeek accepts the mode in that combination and
returned strict JSON on every run. OMA still validates the answer against
the role's schema, so the in-run correction keeps handling schema
mismatches.
The scripted adapter test asserts that every role carries the mode, and
the README documents it next to the shared output ceiling.
* chore: release core v1.19.0, otel v0.1.4, and create-oma-app v0.8.5
* docs(changelog): correct two 1.19.0 claims and record two missed changes
Two entries described behavior that the code and docs do not support.
`restore()` was written as though the run store turns it into the resume
command. It is the resume command either way; `docs/run-store.md` states that
directly. What a run store adds is making a suspended record eligible and
taking the lease before the approval ledger is reconciled.
The `INVALID_TEAM_MAX_CONCURRENCY` entry asked readers to review `onProgress`
handlers that match event codes exhaustively. `OrchestratorEvent.type` gained
no member, `warning` already existed, and `data` is typed `unknown`, so no
exhaustive match can break. The entry now states what was added instead of
implying a compatibility review.
Two published changes were not recorded. The `delegate_to_agent` pool-exhausted
error text changed with the team concurrency work, and it is tool output rather
than a comment. The `@open-multi-agent/core` npm description was rewritten,
which is the first thing a reader sees on the package page.
Also drops "according to the supplied evidence" from the DeepSeek entry. It
described how the entry was written rather than how the name behaves.
* docs(changelog): describe the scaffolded _env.example change precisely
The 1.19.0 compatibility note said create-oma-app now scaffolds
OMA_MODEL=deepseek-flash. The template only names the model in a commented
DeepSeek block, so a generated project does not enable it by default. Say
what the file carries instead of implying an active setting.
* docs(changelog): name the scaffolded file as generated projects see it
The template ships as `_env.example` because npm strips real dotfiles on
publish, and `scaffold.ts` renames it to `.env.example` in the generated
project. A reader of this note has the generated project in front of them,
where the underscore form never appears.
---------
Co-authored-by: oma-release-bot[bot] <315310457+oma-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Jack Chen <jack@yuanasi.com>
Two of the four analysis roles have failed with
`STRUCTURED_OUTPUT_VALIDATION_FAILED`, and they are exactly the two whose
schema declares a nested object with more root-level properties after it.
The other two schemas are flat and have never failed.
Each schema reaches the model as the JSON Schema embedded in its system
prompt, and the model emits properties in that order. With `changelog` in
the middle, the model has to close that nested object before the next
root-level property, across roughly five thousand characters of dense
content. On 2026-09-11 the planner did not: both its first attempt and its
repair retry put `risks` and `rationale` inside `changelog`, closed the
`rationale` array with `}` instead of `]`, and omitted the two remaining
closing braces. The 2026-09-04 change-analyst run failed the same way.
Declaring the nested object last removes the boundary instead of asking the
model to hold it. Nothing follows it, so the tail is a plain `]}}`. Field
order does not affect validation, since Zod matches by key, and the emitted
plan is unchanged.
A test asserts the invariant for all four schemas by reading the JSON Schema
out of the instruction the model actually receives, so a later edit that
reintroduces a trailing root property fails in CI rather than on a Friday.
This does not change the model. `deepseek-flash` handled both evidence roles
on the same run without trouble, and stacking a model change on top would
make the next result impossible to attribute.
DeepSeek retired the V4-Flash model and named the current generation
`deepseek-flash`, with no version in the name. `deepseek-v4-flash` is still
accepted, but DeepSeek documents it as a temporary compatibility name and
already serves those requests from V4.1-Flash.
The repository handed that name to users in the places most likely to be
copied: the `oma` CLI default for `provider: 'deepseek'`, the adapter JSDoc
that ships in the type declarations, the provider table in docs, the
scaffolder env template, and every DeepSeek example. A copied name that the
vendor calls temporary has an expiry date the reader cannot see.
The provider table and the adapter JSDoc also stated that `deepseek-v4-flash`
resolves to DeepSeek-V4-Flash-0731. That model is retired, so the sentence
described a routing that no longer happens.
Two corrections of the same kind travel with it. The thinking-mode note
claimed the V4 models run at `high` effort, but the adapter forwards whatever
effort the caller passes and sets none itself, so the claim is dropped rather
than restated. Three `examples/basics` headers offered `deepseek-chat`, which
DeepSeek retired outright on 2026-07-24 and which no longer resolves at all.
The release bot moves to the same name. Callers that pass an explicit model
are unaffected, and the old name keeps working for as long as DeepSeek
accepts it.
Left unchanged on purpose: `bench/config.json`, whose values are frozen
controls for an already-run benchmark; the AtlasCloud pages, whose IDs come
from that vendor's own model library; and the changelog history.
`npm run bench:ab:test` failed on any CRLF checkout with `README does not
document the "run_id" column`, even though bench/README.md:204 lists it first.
The repository has no `.gitattributes` forcing LF, so the file arrives with
CRLF on a Windows clone.
The test locates the end of the column list with `section.indexOf('\n\n', ...)`.
A CRLF paragraph break is `\r\n\r\n`, which contains no `\n\n` substring, so the
search returns -1, the slice matches no columns, and the assertion fails on the
first one. Linux CI checks out LF, so the drift guard only ever broke for
contributors on Windows.
Normalize CRLF to LF before slicing. This is the only place in the file that
reads a file from disk and splits on a paragraph break; `fromCSV` already
tolerates CRLF and has its own test.
The field was public and typed but never read, so a caller who set
`maxConcurrency: 3` on a team typechecked cleanly, got no warning, and ran
at the orchestrator's value (5 by default). The class JSDoc example
demonstrated the dead field, and 22 examples copied it.
Wire it to the pool that runs the team. The team cap intersects with
`OrchestratorConfig.maxConcurrency` rather than replacing it: the smaller
of the two wins, so a team can narrow the pool for its own runs but never
widen it past the orchestrator ceiling. This covers all three `buildPool`
call sites, each of which already has the team in scope.
An unusable cap reports an `INVALID_TEAM_MAX_CONCURRENCY` warning on
`onProgress` and falls back to the orchestrator value, which is what those
configs already did while the field was ignored. Rejecting them would make
this a breaking change under the definition in RELEASING.md, and a value
computed from the environment is a realistic way to arrive at `NaN`. The
check still cannot be left to `Semaphore`, which rejects only values below
1 and would let `NaN` through to a pool that never grants a slot.
Every in-repo example sets the team cap equal to its orchestrator cap, so
their behavior is unchanged.
Align the agent instruction files with the current CLAUDE.md guidance: keep only what a session cannot derive from the tree, and load package-specific rules on demand.
- Replace the workspace table and the docs table with pointers to `ls packages/` and docs/README.md, which already indexes every page.
- Split the dependency-pinning rule into rule and rationale, and reflow the run-store invariant onto one line.
- Move the architecture entry points, the examples lint exclusion, and the adapter checklist into packages/core/AGENTS.md, with a CLAUDE.md shim that imports it.
AI SDK 7 deprecates `StreamTextResult.fullStream` in favor of `stream`.
The example pins `ai@^7.0.0`, so the reference route was reading a
deprecated property that new readers would copy into their own code.
`packages/core/src/llm/ai-sdk.ts` keeps `fullStream` deliberately. Core
declares `ai` as a peer dependency across `^5.0.0 || ^6.0.0 || ^7.0.0`,
and `StreamTextResult` in v5 and v6 exposes no `stream` property at all,
so switching there would break those consumers at runtime. `fullStream`
is deprecated in v7 but not removed, so it stays correct for all three.
Verified with `tsc --noEmit` against the example's own tsconfig.
Anyone following this example got a blank article back. The route read
the team's answer from agentResults.get('coordinator'), but a goal this
short is routed to the single-agent path, where the orchestrator
publishes the answer under the winning agent's own name instead. The
lookup returned undefined, the ?? '' fallback swallowed it, and the app
streamed an empty Team Output section.
- Pass an explicit mode: 'team' so the demo runs the topology this
example teaches. Explicit mode outranks the router.
- Read the terminal agent as a fallback so the route returns the article
under either topology.
- Check success before streaming. A plan validation failure leaves the
coordinator's unparsed plan under the 'coordinator' key, so testing
for an empty string would stream planning scratch as the article.
- Raise maxDuration to 360 and say "a few minutes" in the placeholder,
to match the measured team path: seven runs on deepseek-v4-flash took
137 to 311 seconds, median 217, from one machine.
The run store landed in #587 without a changelog entry. It meets both
warranted tests in .github/RELEASING.md: it adds public exports and a new
config field, and it changes runtime behavior an unchanged caller can
observe once a run store is configured.
Records the feature under Added and the fail-closed ownership and
lifecycle write semantics under Changed, both scoped to the opt-in so a
reader can tell that runs without a run store are unaffected.
A checkpoint records what a run can resume from, not who is allowed to
advance it. Two processes can restore the same snapshot and both continue
it, and a worker that stalled past its own liveness can wake up and
overwrite the state a replacement has since written.
Add an opt-in `runStore`: one authoritative, versioned record per logical
run holding its lifecycle status, execution lease, and a monotonic fencing
token.
- `RunRecord` keeps `version` for optimistic concurrency and `fencingToken`
for ownership, so a worker holding an old token stays detectable across
any number of unrelated writes. Legal transitions are enumerated and
terminal statuses have no outgoing edge.
- `RunStore` is three methods plus an explicit `atomicity` declaration.
Lease expiry, fencing, transition legality, and command idempotency live
above the seam in `RunLedger`, so a Postgres or Redis backend reproduces
storage semantics rather than a state machine.
- `MemoryStoreRunStore` adapts any `MemoryStore` with compare-and-set and
defaults to `atomicity: 'process'`. `FileStore` remains the sequential
restart reference store and is not presented as a lease backend.
- The orchestrator acquires the lease before it dispatches, fences every
checkpoint write with its token, stops the run at the dispatch gate when
the lease is lost, and records the terminal transition. A worker that
lost its lease writes no terminal status and never reports success.
- `restore()` is the resume command: it makes a suspended record eligible
and takes ownership before reconciling the approval ledger.
Ownership and lifecycle writes fail closed rather than inheriting the
best-effort semantics of checkpoint and telemetry writes. With no run store
configured, existing single-process checkpoint and restore behavior is
unchanged.
Ships a reusable `RunStore` conformance suite covering create races,
version-matched compare-and-set, lease expiry, takeover, stale-writer
rejection, and terminal closure, so an out-of-repository backend can prove
the same contract.
Closes#481
- Updates the copyright line in LICENSE and the three package copies, adds a maintainer line to the package READMEs, and sets `author` in the published package.json files. CONTRIBUTORS.md and CHANGELOG.md updated accordingly.
- Copyrights of other contributors and the MIT license terms are unchanged.
Seven comments in packages/core/src described behavior the code does not
have, pointing readers who configure these APIs the wrong way:
- OrchestratorConfig.onAgentStream said runTasks() never calls it. Both
runTeam() and runTasks() dispatch through executeQueue, which reads the
callback; only runAgent() stays outside the queue. AgentStreamTrace
repeated the same claim.
- The StreamEvent list omitted loop_detected and said a done event always
carries an LLMResponse. That is true at the adapter layer only: the
runner yields RunResult and Agent yields AgentRunResult. AgentRunner
.stream() had the same omission plus a stray indent.
- TokenBudgetExceededError and CostBudgetExceededError said "Raised when",
but the framework never throws them. It constructs them as values and
surfaces them through classifyRunFailure as status and errorInfo, or as
budget_exceeded stream data.
- isRetryableError documented four terminal cases out of eleven, omitting
the egress, adapter-capability, journal-lineage and routing branches.
- JournalLineageError now records why its code deliberately reuses the
verifyRun failure code MISSING_CONTEXT_REPLACE. The value is unchanged.
- The RedactingStore example passed FileStore an options object; the
constructor takes a path string.
- The MCP requestTimeoutMs option bounds every tools/call as well, not
only connect and each tools/list page.
Adds runTasks and runAgent coverage for onAgentStream beside the existing
runTeam test, so the corrected sentence is pinned by a test rather than by
a comment alone.
No behavior changes.
Systematic documentation review: 12 new docs pages (coordinator, streaming, budgets and limits, errors, hooks and callbacks, glossary, production checklist, Run Viewer, MCP, sandbox and shell, evaluation in CI, routing evaluation) plus a docs index; the three longest guides split with stub sections that keep existing anchors; observability release records moved to docs/internal; statements re-verified against the code and corrected; READMEs, AGENTS.md, CONTRIBUTING.md, and issue templates synced. Documentation only, no behavior change.
Move the approval and checkpoint code sample out of the first screen into a collapsed block under the getting-started section in all four README copies, so the top of the page carries identity and trust signals only.
The subsystem directory list and the subpath export list in AGENTS.md had drifted since #457: approval/, journal/, utils/ and the /shell entry point landed without either sentence being updated. Replace both lists
with discovery commands and name the things that do have to move together when an entry point changes: the exports declaration, the backing source file, and the required entry-point list in the CI package job.
Add a Release bot row to the validation matrix, add docs/egress-policy.md to the subsystem table, and extend the adapter checklist with the egress decision that prepareProviderBaseURL forces on every provider.
Replace the first-screen GIF with a short TypeScript sample that shows the provider and endpoint choice, a suspending onToolCall gate, and a checkpointed runTeam call. Move the GIF to the Why OMA section in the root READMEs and to the Observability section in the package READMEs, each with a caption, and update the intro sentence that pointed at the dashboard above.
Tighten the first screen in all four README copies: logo in the title block, one sentence under the tagline carrying environment, approval, and record, and Node.js and supply-chain audit badges. Qualify the approval, observability, and external-backend bullets so they match what the code provides, link the self-hosting page, and update the npm package description to the same wording without a hard-coded provider count.
Reword the local-model claim in providers.md to the models behind runnable examples, add framework-versus-integrator boundaries to durable-approvals.md, external-agents.md, and run-journal.md, and add docs/self-hosting.md covering runtime footprint, network scope, state locations, redaction, and air-gapped deployment.
Two things the v1.18.0 release surfaced, both in text the bot publishes.
buildReleasePrTitle hard-coded core and create-oma-app, so a release that also
republished otel could never say so: v1.18.0 shipped otel 0.1.3 under a title
naming two packages. The signal was already present, because the body's package
table switches on `bumps.otel === null`, and only the title ignored it. The
title now reads the same condition. The two-package string is unchanged byte for
byte, and a test pins the `chore: release core vX.Y.Z` prefix in both shapes:
prepareReleasePr recognizes an already-open release PR by it, and the title is
also the release commit subject.
The Thanks section credited contributors as plain text, so the body reached
nobody it thanked. A contributor is now @-mentioned, but only when the name is a
login GitHub itself confirmed. The display-name fallback stays plain, because
v1.17.0 credited `s4kura` for #549 when the author was `Iams4kura`, an account
someone else holds. As text that was a wrong name; as an @-mention it would have
notified an uninvolved stranger in a body this bot publishes without anyone
reviewing it. ReleaseContributor now carries isLogin out of all three resolution
paths, so a confirmed login and a degraded display name stay distinguishable at
the point that renders the credit.
* chore: release core v1.18.0 and create-oma-app v0.8.4
* docs(changelog): name the otel release and the judgePrompt call-count change
Two gaps in the generated 1.18.0 entry.
The entry names @open-multi-agent/otel twice but never its version, so a reader
cannot tell which release carries upstream-policy. Both opt-ins are required to
get tool input/output into OpenTelemetry, which makes that number exactly the
one a reader needs, and earlier entries stated otel's version even when it was
not republished. The generated GitHub Release body lists package versions in its
own section, so the omission was confined to this file.
judgePrompt also changed how often it runs, not only what it may return. #563
moved it from once per score() call to once per judge, which a caller whose
judgePrompt is costly or has side effects observes directly. The Compatibility
note is narrowed to the signature, which is the part that actually stayed the
same.
---------
Co-authored-by: oma-release-bot[bot] <315310457+oma-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Jack Chen <jack@yuanasi.com>
The 2026-09-04 weekly run failed with a structured-output validation error, but
the output ceiling was the real cause. change-analyst spent 19,822 characters of
reasoning against its 4,500-token cap and returned empty text, so there was no
JSON to validate. The single in-run correction that followed emitted malformed
JSON, and both dependent synthesis tasks failed with it.
This is the second time the same defect has broken the weekly cadence. #519 hit
it on release-reviewer at 3,500 tokens with the identical signature, empty text
after the budget went to reasoning, and fixed it by disabling thinking for the
two synthesis agents. That treated the symptom on the wrong axis and left the
two evidence roles carrying the same bug.
Reasoning and the answer are billed against one budget, so the ceiling is now
sized for both: a single 64,000-token limit shared by every role, roughly
fourteen times the reasoning this run actually produced. An unused ceiling costs
nothing, and the run-level token budget cannot stand in for it because it is
only checked after a call returns, never during one.
With the budget no longer the binding constraint, thinking goes back on for the
synthesis agents and every role moves to DeepSeek's max effort.
The three timeouts move as one chain, because raising the token ceiling alone
would have traded a validation failure for a timeout. An evidence role's worst
case is a tool turn, an answer, and one correction, so 3 x 180s fits inside its
600s. The DAG's worst case is the evidence pair in parallel and then both
synthesis agents in series, so 3 x 600s fits inside a 30-minute deadline, raised
from 10. That leaves the job's 45-minute timeout room for checkout, install,
build, and the deterministic PR work that follows the analysis.
The orchestrator test now asserts the per-role thinking config alongside the
token ceiling. #519 removed thinking from two agents with no assertion to record
it, which is part of why the regression stayed invisible.
Refs #574.
The examples index is the first page most visitors open after the root
README, but it never said the scripts import the framework source and
need a repository checkout, which examples run without an API key, or
that four integrations existed at all.
- Add a "Run an example" block (clone, install, key, npx tsx) and note
the two exceptions: observability-v2 needs a build, apps have their
own start scripts. Explain how to port a script into a real project.
- Add a "No API key needed" table for the seven offline examples.
- Let the basics scripts switch provider through OMA_PROVIDER and
OMA_MODEL instead of hard-coding Claude, following the convention the
cookbook already uses.
- List external-agent-acp, external-agent-process, with-engram, and
with-tencentdb-memory, and fail test:example-catalog when a catalog
entry has no link in the index so it cannot drift again.
- Reorder sections so the 20-row providers table no longer sits between
basics and the patterns and cookbook tables; mark production/ as empty.
- Align integrations/README.md with the apps grouping and define the
mcp-<server>.ts versus with-<product>/ naming by example shape.
`judgePrompt` ran once per `score()` call and could only return a string, so
every judge in a quorum received the same text prompt. A judge scorer could
therefore never see a non-text artifact: an agent that produces an image or a
rendered document could only be scored on whatever text it emitted alongside
the work, which is the agent describing itself rather than the work.
It now runs once per judge and may return either a string, wrapped by the
existing text template exactly as before, or a complete `readonly LLMMessage[]`
passed straight to `Agent.run()`. A caller can branch on `judge.capabilities`
to give a vision-tagged judge an image block while a cheaper text-only judge in
the same quorum gets a text fallback. The string path is unchanged, and the old
`(context) => string` signature still assigns to the widened type.
The structured path deliberately hands the caller everything: OMA adds neither
the case template nor the verdict-schema instruction, so a caller returning
messages must append `buildStructuredOutputInstruction` itself or the judge is
never told to emit the JSON `parseVerdict` requires. `docs/evaluation.md` says
so in prose, and the tests fail when that instruction is missing rather than
passing on a mock that returns JSON regardless.
Locating the artifact stays the caller's job. `ScorerContext` exposes the run
transcript, not a produced artifact, and a tool-produced image sits nested in a
`tool_result` block rather than as a top-level image block, so the documented
helper searches both shapes.
Still out of scope and unchanged here: no per-judge failure isolation, so one
judge failing on content it cannot handle fails the whole `score()` call. No
new options, no `ScorerContext` change, no persistence change.
`MemoryStore` backs shared memory, checkpoint/resume, and the durable approval
ledger, and `docs/shared-memory.md` invites callers to implement it against
Redis, Postgres, or a vendor memory service. It was nevertheless the only one of
these seams with no shared test, so nothing stated which behaviors an outside
implementation has to reproduce.
The new suite runs unchanged against `InMemoryStore`, `FileStore`, and
`RedactingStore(FileStore)`. The decorator earns its place by being awkward: it
omits `compareAndSet` outright and rewrites values on the way in, so keeping it
green proves the contract stays satisfiable by a store that is legitimately not
shaped like the others. Optional methods are probed the way `SharedMemory`
probes them and skip visibly rather than passing vacuously.
Three things the suite refuses to require, each recorded with its reason: a
defensive copy on read, a stable `list()` order, and expiry filtering. All three
would freeze an implementation detail or duplicate work owned by `SharedMemory`.
A companion group asserts the inverse of every required invariant against a
store built to get exactly that thing wrong, so an invariant that could never
fail cannot hide in the suite.
The contract suites now also type-check. `tests/` is excluded from every
committed tsconfig and Vitest strips types without checking them, so a suite
could drift onto a non-exported type and still go green. A directory tsconfig
plus the in-test tsc idiom already used for the excluded example projects closes
that, and was verified to fail on an injected error rather than silently
matching nothing.
Routing the existing TraceStore and EvalStore suites through the public barrels
makes them describe the contract using the surface an external implementer can
reach, and immediately surfaced two type errors the missing check had hidden: a
write to the readonly `TraceRecord.attributes`, and a `TraceRecord` cast with
insufficient overlap.
Deliberately internal: no new export, no new subpath, and no change to
`packages/core/package.json`. Whether to ship a conformance suite third parties
can run against their own stores is a separate decision, since it turns these
interfaces into a compatibility commitment.
`publish.yml` decided whether a commit on main was a release by asking
whether packages/core/package.json and packages/create-oma-app/package.json
had changed at all. Those two files carry more than a version: dependency
specs, `exports`, `files`, `engines`, keywords. Any edit to either one read as
a release.
98516ce was the first commit to hit it. It only pinned dependency specs, and
neither version moved, but both manifests changed, so `detect_release_commit`
reported `is_release=true` and the publish job started. It entered the
`npm-release` environment, minted a contents:write GitHub App token, checked
out, installed, built, and then stopped at the publisher's own guard:
98516ce... is not a release commit: @open-multi-agent/core did not
increment from its first parent.
Nothing was published, tagged, or released, and the registry versions are
untouched, so the safety net did its job. But the outer gate should not have
opened. A dependency bump is the most ordinary maintenance there is, and it
should not cost a write-scoped token and a red Publish run every time.
The detection now reads the `version` field of both manifests at the release
commit and at its first parent and stops unless both moved, which is the same
fact the publisher re-derives later. A manifest that does not exist at the
release commit is a no-op rather than an error, matching what the old file
comparison concluded for the pre-monorepo tags. A manifest that exists but
declares no version fails the job, because that is a broken tree rather than
an ordinary commit.
It also closes a fail-open path found while building the check harness.
`git diff --quiet` exits 1 for "changed" and 128 for "could not run", and the
old step read both as "changed", so any git failure resolved to
`is_release=true`. Reading the version field fails closed instead. Unreachable
in practice, since checkout and the ancestry verification both run first, but
a publish gate should not lean on that.
Checked against every `v*` tag in the repository. All three tags from after
publish.yml existed — v1.16.0, v1.16.1, v1.17.0 — still detect as releases,
as do v1.8.0 through v1.10.0 and v1.13.0 through v1.15.0. v1.11.0 and v1.12.1
now report false because their tagged commits genuinely did not move both
versions; both predate this workflow, and the publisher would have refused
them for exactly that reason, so the gate now agrees with the publisher
instead of deferring the disagreement to a failed job.
A library that gets installed into someone else's backend is judged partly on
what its own install pulls down. Three changes tighten that, and two questions
turned out to need no change at all.
The line this draws is what ships. Every dependency that stays inside the
repository is now pinned to the exact version the lockfile already resolved:
all 27 devDependencies across the four workspaces, plus the private release
bot's own zod. The lockfile change is 28 spec lines with no
resolution moved, no entry added or removed, and all 61 platform-specific
entries intact. A new root .npmrc sets save-exact=true so the next one arrives
pinned rather than as a range nobody notices.
What a consumer installs keeps its range: `dependencies` in packages/core, the
only published runtime block here, every `peerDependencies`, and otel's core
compatibility range. An exact version in those blocks overrides the consumer's
own resolution and stops their `npm audit fix` from reaching a patched build
until we cut a release, which is a worse trade than the install-time drift it
prevents. save-exact will still pin a new dependency added to one of them, so
the package job now asserts all three published manifests and fails when one
is left exact, next to the assertion that already covers otel's range.
min-release-age is deliberately not set. It first shipped in npm 11.10.0, and
the CI matrix runs Node 20 and 22, which bundle npm 10.8.2 and 10.9.8. Those
two accept the key, echo it back from `npm config get`, and never act on it,
so a quarantine window would hold on one leg of the matrix while silently
doing nothing on the other two. It also has no effect on `npm ci`, which
installs the lockfile verbatim.
Every `npm ci` that installs this repository's own tree now passes
--ignore-scripts, so a compromised dependency cannot run an install hook on a
runner that holds release credentials. Only three dependencies declare a
postinstall at all, and the full lint, test, build, scaffold, and example
suites pass on a tree installed that way. The `npm install` calls that stand
in for a real consumer are deliberately left alone: their job is to reproduce
what a user's install actually does, and hardening them would make the smoke
tests prove less than they claim.
A new scheduled Supply chain audit workflow asks two questions about the
dependencies that reach a consumer, an hour before the weekly release bot so a
finding lands before the release PR is drafted. --omit=dev is the point of
running it separately: auditing the full tree reports eighteen advisories,
every one of them in test and fixture tooling that never ships, and a
permanently red job is one nobody reads. The two steps fail differently.
`npm audit` reports into the run summary, because a new upstream advisory is
not this repository misbehaving and may have no fixed version yet.
`npm audit signatures` fails, because it compares served bytes against the
registry's own signature, so a mismatch is tampering rather than churn; all
27 production packages verify today.
Two things needed no change. npm provenance is already live: publish.yml
publishes through npm trusted publishing with id-token: write, which attaches
SLSA attestations on its own, and every version since core 1.16.0, otel 0.1.2,
and create-oma-app 0.8.1 carries one. And .npmrc left .gitignore in #198
because it only restated the default registry; it comes back tracked, with the
ignore rule narrowed so a contributor's own nested .npmrc stays out.
Roughly 76% of visits to open-multi-agent.com are reported as direct. A
share of that is genuine, and the rest is channels that drop the referrer
on the way: messaging apps, in-app webviews, email clients, QR codes. UTM
parameters are the only thing that tells the two apart, which is why the
site's GitHub link fields have carried them since the short links landed.
The READMEs were the remaining untagged surface, and every one of these ten
links is anchor text. So they take full canonical URLs rather than the /go/
short links: the URL is never shown, and a canonical URL with parameters
keeps working without depending on a redirect rule surviving.
utm_source=github utm_medium=readme README.md, README_zh.md
utm_source=github utm_medium=package_readme core/README_zh.md
utm_source=npm utm_medium=package_readme core/README.md
packages/core/README.md is the only file in the repository rendered on two
platforms: it ships in the npm tarball and is also browsable on GitHub. One
file carries one source, and it is sourced to npm because the package page
is its primary audience while the folder view is reached from inside the
repo. packages/core/README_zh.md is not in the tarball, so it stays on
github.
Nothing is tagged more finely than that. Language needs no utm_campaign
because the /zh/ path prefix already separates it, and the three nav
destinations are three different paths, so link position needs no medium of
its own. Each of the ten links has a unique source, medium and path triple.
The bare host form gains a trailing slash to match the site's
trailingSlash: 'always' and skip an edge redirect.
Deliberately left untagged: docs/*.md and the release notes. The site
vendors both, and its link rewriter only rewrites relative links, so an
absolute tagged URL would come back as an on-site link carrying a campaign.
The example catalog's controlled vocabulary had no term for runtime plan repair, so the only example that runs recovery in repairable mode was filed under generic run-tasks and structured-output capabilities and could not be reached by a capability filter. Adds adaptive-recovery to the runtime validator and the public catalog schema, and tags commission-reconciliation-recovery with it.
The PR checklist existed twice, in .github/pull_request_template.md and in
CONTRIBUTING's "PR Checklist" section. Both were written in #381 and had
already drifted: four items against five, different wording for the same
four concepts, and "The PR links a relevant issue when one exists" present
only in CONTRIBUTING. Keep the checklist in the template, where the work
happens, add the missing fifth item, and leave CONTRIBUTING pointing at it.
The template also carried no outbound link of any kind, including on the
line that referred to "the package ownership boundaries documented in
CONTRIBUTING". Relative links do not resolve inside a PR body, so the added
links are absolute.
Neither file stated the PR title convention, although every merged PR
follows it. squash_merge_commit_title is COMMIT_OR_PR_TITLE, so on a
multi-commit PR the title becomes the commit subject, and RELEASING.md
reads a conventional-commits `!` marker on a merged commit as a
breaking-change signal. Say it once, at the top of the template.
The issue templates asked reporters for Acceptance criteria, Target paths,
Out of scope, Open decisions, and Blockers. Those are machine-readable
fields added in 098371c for the maintainer issue-to-PR bot; #520 removed
the bot but left the fields, so outside reporters were still asked for
exact repository-relative paths with no globs. Both templates now match
098371c^ byte for byte, which also drops the dangling "Maintainer Bot will
route the issue to manual handling" comment.
Follow-up to #548, which introduced VideoBlock. Two gaps were missed when that
PR was reviewed.
The four adapters that cannot map video threw a bare Error. isRetryableError
returns true for any Error without a status, so executeWithRetry treated a
deterministic capability gap as transient: it ran the full backoff ladder and
rewrote a checkpoint per attempt before failing the same way, and the failure
was recorded as retryable. UnsupportedContentBlockError makes that terminal,
names the adapter that rejected the block, and carries a code alongside the
existing UnsupportedToolResultContentError.
VideoBlock is the first top-level content block with a URL source, so it
inherited no validation. That source is dereferenced by the provider rather
than by OMA, and a file: path or a data URL pasted into the field was forwarded
verbatim; an unvalidated media_type could also alter the data URL built for
inline bytes. Public entry points now run video sources through the same
copyMediaSource rules already applied to tool-result media, so the documented
HTTP(S) constraint is enforced rather than aspirational. Provider-private
upload handles such as MiniMax's mm_file:// are rejected: supporting them needs
a source variant of its own, not an unchecked string.
Also from the same review:
- estimateTokens charged an inline video the 64-character fixed cost images
get, hiding a multi-megabyte payload from every context strategy keyed off
the estimate. It is now sized like rich tool-result media, so the attachment
that needs compacting is the one that triggers compaction.
- stripImageBlocksForSummary handles video too, so it is now
stripMediaBlocksForSummary.
- The two double casts in openai-common are narrowed to a single seam, and the
video_url extension type now records that it is not an OpenAI part.
- docs/structured-input.md states the rule the adapters actually follow:
OpenAI-shaped adapters forward and let the provider decide, adapters with a
typed content union reject locally. Bedrock and Gemini support video
upstream, so the gap there is OMA's mapping rather than theirs.
Adds a no-key cookbook that walks the full repairable-recovery lifecycle on a synthetic commission reconciliation: three source-scoped investigations produce a typed temporal-evidence gap, a named Replanner appends targeted agreement and policy history lookups plus replacement reconciliation and output tasks, and the run either completes with a RECONCILED result or stops at the configured revision limit and fails closed to MANUAL_REVIEW_REQUIRED outside the task graph. Documents the recipe in docs/adaptive-recovery.md and registers it in the example catalog.
Adds a public VideoBlock content block with inline base64 and URL sources, serialized as an OpenAI-compatible video_url content part so MiniMax-M3 can receive video input. Video attachments are replaced with placeholders when preparing context summaries, keeping binary payloads out of the summarisation prompt. Adapters with a typed content union reject the block explicitly.
Every observability example used an in-process exporter, which proves the
adapter's semantics but never serializes a span or opens a socket. Nothing in
the repository showed the path an OpenTelemetry user actually takes, so the
exporter choice, Resource, auth headers, and shutdown order were left for each
reader to reconstruct.
`otlp-backend.ts` runs OTel SDK to OTLP/HTTP exporter to HTTP to a backend, and
prints what crossed the wire. The backend is a loopback listener the example
starts itself, so it still needs no API key, no external collector, and no
internet access; `OTLP_URL` and `OTLP_HEADERS` redirect the same payload to a
hosted vendor.
It also demonstrates two things that are easy to get wrong. `service.name`
belongs to the provider's Resource, and a provider built without one files
every span under `unknown_service:node`. Tool input and output require both
halves of the capture opt-in, so the example sets the core policy and the
adapter mode together and states what the run exports without them.
`runToolDemo` joins the shared demo runtime so the example can show a real
`execute_tool` span; the existing `runDemo` export is unchanged. The example is
registered in the catalog and in the smoke script, which now runs eight
examples.
The v2 sink path dropped tool arguments and results that the legacy `onTrace`
callback still delivered, so migrating to `observability.sinks` (and from there
to any OpenTelemetry backend) silently lost data the old callback provided.
`TraceCapturePolicy` already declared `toolInput`/`toolOutput`, and
`SensitiveDataProcessor` already filtered on them, but no instrumentation site
produced the attributes, leaving both switches inert.
`AgentRunner` now attaches `oma.tool.input` and `oma.tool.output` to the
`execute_tool` span when the policy opts in. `TraceRuntime.capturesToolIO`
carries that decision so the serialization is skipped entirely by default,
keeping unopted runs byte-for-byte unchanged. Values are redacted at the
instrumentation site as well as in the processor, so a runtime constructed
outside the orchestrator does not bypass redaction.
`@open-multi-agent/otel` gains `contentCapture: { mode: 'upstream-policy' }`,
which forwards those two attributes and nothing else. It is the separately
reviewed upstream policy the package's reserved extension point was waiting
for: the adapter setting exports nothing on its own, because without the core
policy the attributes are never recorded. Prompt, completion, raw payload,
credential, and reasoning content stay excluded under every mode.
Docs now state which capture fields have producers, since `prompt` and
`completion` remain filters over attributes no v2 site emits, and record that
`service.name` comes from the application's own TracerProvider Resource rather
than from adapter metadata.
The assertion compared how long the batch took to settle against a fraction
of the flush interval, so it rode on how promptly a loaded runner fires a
timer rather than on the invariant it names. It failed under parallel load
on a 5ms margin, was widened to roughly 150ms in #556, and failed again on
that margin during the v1.17.0 release CI with
`expected 450 to be less than 450`.
Widening it again would not help: timer overshoot and a genuinely reset
window move the measurement in the same direction, so a looser bound buys
stability by giving up discrimination.
Drive the clock instead. The batch either flushes on the deadline the first
append opened or it does not, and an empty timer queue at that deadline is
what rules out a second one left behind by a reopened window. The timer
count is asserted before awaiting the writes, because after them a real
reset would hang until the test times out and report nothing about why.
The Thanks section fell back to the commit's display name whenever the
author's address was not a GitHub noreply. A display name is not a handle
and can belong to someone else: v1.17.0 credited `s4kura` for #549 when the
author's login is `Iams4kura`, an account another person holds. The previous
release only looked correct because that contributor's commits happened to
carry a noreply address, so correctness rode on an email privacy setting.
Ask GitHub which account claims the commit when the address does not carry a
login. The lookup is best-effort and cached per address, so an unlinked
address or a failed request degrades to the previous behavior rather than
failing a publish over a name.
* chore: release core v1.17.0 and create-oma-app v0.8.3
* docs(changelog): state the actual JSON Schema required-field change
The generated Compatibility section described the release bot's own review
scope instead of the release. It also summarized the #549 alignment as
"potentially stricter", which is half right: switching from a wrapper-type
check to Zod's isOptional() makes .nullable() properties required and stops
z.any() and z.unknown() properties from being required.
Replace both bullets with what changed and who it reaches. The export claim
is verified against the v1.16.1 barrel and subpath exports; the schema
behavior against zod 3.25.76.
---------
Co-authored-by: oma-release-bot[bot] <315310457+oma-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Jack Chen <jack@yuanasi.com>
GitHub never delivered the Friday 2026-08-28 trigger; no run record exists
for it. The daily provider canary shows why: its 03:17 UTC schedule ran
~40 minutes late through 08-26, then 10h50m late on 08-27 and 12h01m late
on 08-28. GitHub delays or drops schedules under load, and the top of the
hour is the most contended slot.
Move the trigger to :23 and update the release order documentation so the
documented time matches the workflow.
Reconstructing what an agent saw is only worth something if the log can be
checked against itself. verifyRun() reads a finished run journal cold and
proves every model-visible block is reproducible from the events it names: a
message-bearing event must carry a block with the same canonical hash, or a
context/replace must carry the replacement it produced.
Naming a lineage is not the same as being reproducible from it. A rewrite
that silently replaced a conversation still cites prior events, so a presence
check accepts it; requiring the named event to reproduce the bytes is what
makes an unrecorded rewrite detectable without a full replay.
Eviction and dropped batches are reported as inconclusive rather than counted
against a run, so a bounded journal stays honest about what it can and cannot
answer.
Also fixes two defects in the journal subsystem: a literal NUL byte in
tail-replay.ts made git treat the whole file as binary, and the JSONL
batching-window test asserted against a 5ms timing margin that failed under
parallel load.
The oma verify-run CLI stays deferred.
Context strategies emit a context/replace journal event for every
persistent rewrite, naming what was dropped and carrying every derived
block verbatim, so the conversation the model actually saw stays
reconstructable. enforceLineage now passes with every built-in strategy.
Journaled runs write checkpoint schema v5: v4 plus a journal watermark
captured at snapshot build time, per-task watermarks on in-flight
entries, an optional journal backend reference, and per-block
conversation lineage. Restore replays the journal tail after the stalest
per-task watermark, folding only in-flight runner state under defensive
anchoring checks; any inconsistency discards the whole tail and resumes
from the snapshot alone. A committed tool result that reached the
journal but not the checkpoint is replayed instead of re-executed.
Runs without a journal keep writing v4 byte for byte, and v1 through v4
snapshots keep loading.