For system-level changes, start with docs/design/system.md
and docs/design/roadmap.md, and state which agent
decision gets cheaper or more accurate, which layer owns the change, and which
durable artifact lets the next worker continue. Read
docs/design/ before changing a public surface or
revisiting a design decision, and docs/spec/ when a change
touches the record-stream contract (record shapes, attribution, the session
graph, the cursor). When working on an adapter, read its
runtime page (native API inputs and results, resume
semantics, current OAR mapping, evidence gaps) and update it when the mapping
changes.
Detail lives next to the code:
packages/oar/src/README.md (source layout,
import rules, ownership), sea-trial/README.md
(behavior-suite layout and harness),
experiments/README.md (live probes and their
conclusions).
Run first, test second: while shaping a change, run the real thing and look.
Drive the CLI (pnpm oar ... builds and runs the in-repo oar):
oar run <runtime> "<prompt>" shows a turn's progress live, --record <file>
keeps the run as an oar-voyage/3 JSONL log, and oar list,
oar installation <id>, oar usage <id> and oar models <id> cover the
observation surfaces. Or point a scratch pnpm tsx script at the public
Session API, or reuse an experiment. This is how you find out what correct
looks like before pinning it.
When the check you need has no tool (a CLI flag, a script that replays a recorded run), build it or extend the oar CLI as part of the change: a command someone else can rerun turns "trust me, I checked" into "run this and see".
- Ad-hoc evidence never closes a change. Once the behavior is understood, encode it at the cheapest test layer that can catch a regression (below); the commit gate stays the arbiter.
- Leave the lever behind. A scratch script that earned its keep becomes a test, or an experiment with its conclusion recorded, not a loose file. A tool you built or extended for the run is polished and committed with the change.
- Leave a handoff. For work that spans turns or owners, record the runtime identity, cursor or artifact, current conclusion, and next action. A status message without a continuation is not an accumulated result.
- Public evidence must be readable without a login. Repository docs must
not rely on private Artifact pages as evidence. Commit the report,
reproduction script and selected, redacted results under
experiments/and link them with repository-relative paths. Keep raw credentials, account identifiers and machine-local paths out of published results. - Real logins cost quota. Repeatable ad-hoc runs belong on the mock or
aimock backends; use a real installation deliberately, as with
OAR_TEST=<real id>.
Setup: Node.js 24+, pnpm install.
pnpm run typecheck and both packages' builds use tsrs, pinned exactly to
0.20.0-ts7.1.0-beta in the root devDependencies (TypeScript 7.1 beta
semantics, not TypeScript 6), locally and in CI. On every tsrs upgrade keep
the pin exact and compare the emitted files, especially declarations and
source maps, against the previous tsrs and the matching official
TypeScript 7 build (migration evidence:
timings, comparisons, incremental-cache checks). Native binaries exist for
Linux x64/arm64 (GNU and musl), macOS x64/arm64 and Windows x64; elsewhere
set TSRS_BINARY to a locally built compiler or rely on the required CI
checks (there is no automatic tsc fallback). Incremental build caches live
inside dist and are excluded from the npm packages; removing dist forces
a complete rebuild.
The official typescript dependency also parses source for the spawn-boundary
check in tests/spawn-boundary.test.ts. tests/clean-install.ts checks that a
TypeScript 6 host can consume the packed, tsrs-built declarations. An editor on that workspace TypeScript 6 can report different
diagnostics from CI; tsrs has its own language server
(editor setup).
Type-aware lint uses oxlint/oxlint-tsgolint; tsx, esbuild and vitest are
not replaced by tsrs.
The tests let whoever makes a change, human or coding agent, verify it end to end without a person as the bottleneck: a change is done when the tests prove it, not when someone has eyeballed it. The suite is also the proof that the contracts hold on every runtime, so never weaken an assertion to get past a failure: a red test means the work is not done.
Each layer below validates a different part of a change at a different cost. Run the cheapest one that can catch your likely mistake, escalate as the change gets riskier, and finish with the commit gate.
tests/**/*.test.ts (per-runtime subdirectories included), run by pnpm test
(vitest). Seconds, no vendor binaries. The inner loop for shared mechanisms:
folds, resolvers, process plumbing. Use virtual time via vi.useFakeTimers
(mock Date too when clocks matter).
On Windows, Vitest global setup first runs one real CIM process-table query
(60-second limit), recording total and query duration in the job log and
oar-trial-run/windows-process-table-preparation.jsonl; a failed read or
missing observer identity fails setup. This keeps first-use work outside the
unit tests' timing windows. The standalone behavior entry does not prewarm
CIM, so it exercises product preparation and the cold cleanup path under the
15-second disposal assertion. Product preparation and its separate capture
budget: record contract; cold first-use
timing: Windows CIM experiment.
The Windows watchdog regression keeps launcher, sampler/CIM, nested test
and process-result diagnostics under oar-trial-run/pi-rpc-diagnostics,
outside its disposable fixture directory. The Windows check job uploads
them even when the unit suite fails. Startup records distinguish an entered
sampler, returned initial resource queries, a launched child and an entered
test; a missing checkpoint is an evidence gap, not a cause. The diagnostics
change no time limit or termination assertion.
One suite in sea-trial/cases/, run against interchangeable backends that
OAR_TEST selects (sea-trial/harness/backends.ts; unset means mock, an
unavailable runtime skips locally; OAR_TEST_REQUIRE_AVAILABLE=1 makes an
unavailable installation fail). Write a case for a contract promise every runtime
must honor: it asserts only through the public Session API and must pass on
the mock, the aimock backends and real logins alike, race-honest where
runtimes may legitimately differ.
pnpm sea-trial: the in-process mock fixture. Fast, deterministic, no binaries or network; the default validation for any change to contracts or session behavior.OAR_TEST=<runtime>-aimock pnpm sea-trial(claude-aimock,codex-aimock,pi-aimock,pi-rpc-aimock,pi-durable-aimock,qwen-aimock,grok-aimock,kimi-aimock,opencode-aimock,codebuddy-aimock,dsh-aimock,antigravity-aimock; Qoder usesqoder-native): the real vendor binary and adapter with only the model provider scripted, no login. Use it when you touched a runtime's adapter: it catches real-process integration mistakes the mock cannot.OAR_TEST=scripted pnpm sea-trial: the publicscriptedRuntime(@botiverse/oar/testing) held to the same contract.pnpm tsx sea-trial/all.ts [backend...]: several backends, by defaultmock,mock-shared,mock-isolated, every aimock backend exceptantigravity-aimock, and Qoder native replay. Run it before pushing a change to shared runtime machinery. It runs at mostmax(1, min(4, floor(availableParallelism() / 2)))backends at once, leaving room for native CLIs' own threads and children, and every selected backend runs even after another fails. The bound is local only (CI runs backends on separate VMs) and changes no product or individual test deadline (local overload evidence). Pin local native binaries to the CI versions before comparing results (for example,OAR_CODEBUDDY_BIN=/absolute/path/to/2.179.3/codebuddy): an old installation found on PATH is not evidence about the pinned version.OAR_TEST=<real id>(antigravity,claude,codebuddy,codex,cursor,dsh,grok,kimi,opencode,pi,qoder,qwen): your local installation and login. The final word when vendor reality is in doubt; it costs quota, so never run it by default.
sea-trial/vendor/*.vendor.test.ts, run by
OAR_TEST=<runtime>-aimock pnpm vitest run sea-trial/vendor. The ACP
runtimes' vendor backends (opencode-aimock, kimi-aimock, grok-aimock,
antigravity-aimock, sea-trial/harness/aimock-acp.ts; codebuddy-aimock,
sea-trial/harness/codebuddy-aimock.ts) need the installed CLI; which CI
job runs each is listed under the commit gate. Vendor
tests see the scripted provider's side: request contents, error edges, scripted tool
rounds. Run them when a change affects what a runtime sends upstream or how it
handles vendor-specific behavior; write one for anything that needs the
provider's view or a vendor-specific trigger. Each is gated with
skipIf(process.env.OAR_TEST !== ...); helpers live in
sea-trial/vendor/support/.
The manual OpenCode snapshot comparison
retains existing native logs before HOME cleanup under
OAR_TRIAL_RUN_DIR/native-opencode/. Retention is bounded to four files and
1 MiB per file, with explicit head/tail gaps and omitted file counts. Normal
fixtures do not copy these logs. Raw logs can contain
session IDs and local paths; publish selected evidence only.
Diagnostic retention decisions
explain which other observations remain active and why.
The Antigravity MCP name-clash vendor case retains failure evidence under
OAR_TRIAL_RUN_DIR/agy-mcp-failure/ (inside the existing agy-acp-* CI
artifact). Before deleting its isolated HOME, it reads up to four native
antigravity-cli/log/*.log files, at most 512 KiB from each tail. It also
keeps the echo servers' bounded startup and handshake metadata, without RPC
payloads. Paths, known credentials and credential-bearing native entries
are removed before writing; the manifest identifies missing files and
truncation. Passing cases discard the temporary metadata. Missing startup
records mean startup was not observed, not that no process was launched.
This changes neither the assertion nor the deadline or retry policy.
Capture validation and replay.
experiments/, run by pnpm tsx experiments/<name>.ts. A live probe that
answers one question about a real runtime, kept as evidence with its
conclusion indexed in the experiments README. These are not part of the normal
CI change-validation gate; reach for one when the vendor's actual behavior is
the open question. The manual shared-runtime evidence workflow
runs the account-free experiments/sharing probes to collect platform
observations, including actual MCP, hook/plugin and tool child cwd and
environment. It preserves failures, retains differences as failures, and
does not admit a runtime to sharing or alter the pull-request gate.
The shared cases assert the minimum every backend must honor. The strong
claims (a steer lands in the same turn, a queued input runs as its own turn,
an abort ends with the runtime's own report, a killed process shows up as an
exited response, sub-agent frames attribute) need a real login:
experiments/live-contract.ts <runtime>
writes one voyage log per scenario plus a report.json of observed facts
under oar-trial-run/live-<runtime>-*/. It burns tokens; run it when an
adapter changes what the stream carries, and record the observation in the
runtime's page and the experiments README.
pnpm run check must be green before every commit: typecheck, lint,
pnpm test and the mock behavior suite. Vendor tests are collected by
pnpm test but skip unless OAR_TEST names their backend, so run them for
the backend you touched. Rebase on origin/main before pushing.
CI (.github/workflows/ci.yml) skips a push
or pull request whose changes are all Markdown files or under docs/ or
assets/. Otherwise it runs these jobs:
check:pnpm run checkandpnpm run buildon Linux, macOS and Windows, then native checks: CodeBuddy's official npm installation in an isolated HOME and global prefix with its bin directory off PATH (installed version and an idempotent second install); OpenCode 2.2.7 isolated native tool cleanup (readiness, surviving-peer and final residual-process assertions); and Kimi 2.4.1's native login tests against a local OAuth/model server, including browser suppression and credential writes. The agy CLI 1.5.0 (Antigravity) is installed from official platform downloads with pinned SHA-512 digests (install-antigravity.mjs) and runs theantigravity-aimockbehavior and vendor suites against a local Gemini provider in an isolated home (concurrency 2, a missing installation fails). Each suite requires a successful install; an earlier unrelated check failure does not skip installation. CodeBuddy, OpenCode, Kimi and Antigravity upload diagnostic artifacts, failures included. CodeBuddy records discovery, install, verification and cleanup phases underoar-trial-run/codebuddy-install, enables npm verbose and timing logs in its isolated environment, and saves the latest ten debug/timing files before deleting that HOME (last 1 MiB per file, with truncation marked). A killed npm process may not finish its timing file; the partial debug log and phase records remain useful. The install limit is 180 seconds and the enclosing child limit 240 seconds. Evidence. The browser-entry unit test bundles the complete portable entry for a browser and refuses Node imports or unresolved externals. Synthetic sharing routing and public placement-refusal regressions stay in these required jobs.behavior:pnpm run sea-trialplus the backend's vendor tests on the same three systems forclaude-aimock,codex-aimock,pi-aimock,pi-rpc-aimock(not on Windows, below),pi-durable-aimock,qwen-aimock,dsh-aimock(DSH pinned toDSH_MINIMUM_VERSION0.2.1-rc.5, a missing installation fails) andqoder-native(Qoder pinned to 1.1.81 because native replay stops driving inference from 1.1.82), the last two at shared-case concurrency 2.pi-rpc-minimum: Ubuntu behavior and vendor suites with a separately installed Pi CLI 1.4.0 selected throughOAR_PI_RPC_COMMAND. This guards the explicit-command floor while OAR keeps its current bundled dependency. Any floor increase must update this CI pin andPI_RPC_MINIMUM_VERSIONtogether in the same pull request.pi-rpc-windows (behavior)andpi-rpc-windows (vendor): Windows Pi RPC on separate VMs, so each phase keeps its outcome if the other loses its runner (job ceiling 20 minutes). Both run under the resource sampler, with phase-suffixed artifacts and separate vendor group directories and resource files. The vendor job runs four named steps in sequence (RPC-specific, native Pi, MCP and remaining shared files) that together cover every vendor file exactly once, new files included; each starts Vitest separately with the usual worker settings, and an ordinary failure does not skip later groups. Step names and start/end times stay readable through the GitHub jobs API when logs are not; they identify a group, not a test or why it stopped. Vendor cases keep their 30 to 120 second Vitest limits. An external monitor also stops the test process tree when a case (hooks included) stays active for three minutes, naming it in the resource log; each vendor group has a five-minute command ceiling inside a six-minute step ceiling. Its exit 124 means the diagnostic monitor reached its deadline, not a native test result, and does not prove descendant cleanup (residual processes remain the runner's responsibility). Tree termination runs in the background within a ten-second budget: an unreturned call recordstermination_unreturned, a thrown onetermination_error, and either recordsstage_exitand exits 124 without joining the worker or taking another resource sample; a returned call waits up to five seconds of that budget for root exit. Output handles stay inherited, so native test output reaches the job log. The always-uploaded artifact holds incremental case events, the last active cases, combined Vitest output and redacted Pi RPC stderr tails. None of this changes an assertion, concurrency or native deadline, or establishes the cause of the intermittent Windows hangs.acp-behavior: Kimi 2.4.1, Grok 1.0.70, OpenCode v1 1.19.5 and CodeBuddy 2.179.3 on Linux and macOS: shared behavior and vendor tests against native programs with local scripted providers, isolated configuration, synthetic keys and npm download caches. Shared-case concurrency and Vitest worker count are both 2; CodeBuddy serializes shared cases in its isolated fixture HOME. OpenCode prepares each behavior fixture's database with one serial native open and exit before concurrent cases begin; a preparation failure stops the suite without retries (the independent cold-start observation retains the native database race). A missing installation fails; individual unsupported capabilities remain visible skips. Ceilings are 20 minutes per job and 12 per suite step, changing no case assertion or native deadline. Logs and traces upload even on failure. Evidence and deferred runtimes.browser-demo:pnpm durable-demo:smokein headless Chromium on Linux. The real Pi Durable Harness and native fetch provider run in the browser; provider requests go to local scripted responses using a synthetic key.clean-install:pnpm run clean-install(tests/clean-install.ts) on Linux. Both packages are packed andnpm installed into an empty project: without the optional peers, the root entry and thebrowser,forms,observe,kernel,brands,testing,agentsandagents/reportentries must load and type-check, OAR must probe every built-in runtime, and the line that adds cursor must fail the host's compile; with the SDK added, cursor loads. With the Pi Durable peers added, a pi-durable session opens and its entry bundles for a browser. The CLI, installed after the host's SDK is removed again, lists cursor's models through the SDK it depends on itself. It needs the npm registry.
Outside ci.yml and its pull-request and release gates:
acp-windows.ymlruns the Kimi, Grok and OpenCode v1 ACP backends on Windows after main pushes. All three had intermittent Windows startup or MCP timeouts whose causes remain unknown. The jobs still fail, withoutcontinue-on-erroror retries, and retain logs and traces; daily inspection summarizes them. Grok's launch comparison runs after its tests, so it cannot prewarm them. It retains bootstrap read/decode/write/copy/spawn phases for the explicit Node path and repeats that path and the OAR-resolved executable in the same HOME after either success or failure. The seven launches keep their 15-second initialize and five-second cleanup limits; the diagnostic step remains three minutes. Measurement scope.osv-scanner.ymlscans dependencies daily and reports findings in its job log without failing; only a scanner error fails it.- OpenCode and Grok shared placement is not admitted (explicit
sharedis refused), so their shared session behavior suites and native shared/isolated controls run only in the manualopencode-sharing-experiment.yml(with a configured plugin child-environment comparison) andgrok-sharing-experiment.ymlworkflows, against experimental adapters outside the package.
The Antigravity, Grok, Kimi and OpenCode synthetic ACP fixtures retain incremental
provider requests, matcher selections and streamed response chunks under
acp-provider/ in the existing run artifacts.
They survive a failed assertion or unfinished turn and fixture HOME cleanup;
entries and files have explicit size limits. Headers, URL queries and environment
variables are omitted. This is test evidence only and changes no matcher,
assertion or native timeout (November 6 failures and boundaries).
Windows codex-aimock, every qoder-native and dsh-aimock behavior job, and the Windows
ACP observations, forward child stderr to the job log with
OAR_CHILD_STDERR=inherit, including failures before a session trace exists
(a login's or logout's process is never forwarded: its output may carry secrets).
Artifact upload warns when no trace directory was created; the failed test
or startup step still fails the job. Codex RPC exit errors also retain a
bounded stderr tail without this setting, so use that evidence before
changing a timeout or assertion to address an intermittent startup failure.
Installation readiness and --version execution failures retain the native
error, signal, timeout, exit code and stderr tail in their exception too.
On a one-shot command timeout, diagnostics also snapshot whether Node had
observed the root process's exit and the output streams' close, with the
root's exit time, elapsed duration, code and signal when available. The
snapshot is taken before cleanup; a later cleanup-induced exit cannot replace
it. This distinguishes an exited root with open pipes from an exit not yet
observed without changing deadlines, pipe draining or process cleanup.
The Windows Codex comparison (nine default/two/one file-worker samples, including a cleanup timeout in the single-worker group) does not establish a worker-limit remedy; CI keeps its per-case concurrency, assertions and deadlines.
-
Probe reality first. Write an experiment that answers how the vendor actually behaves (session lifecycle, event stream, error shapes) and index its conclusion in the experiments README. Build the adapter on that evidence, not on the vendor's docs, and against the runtime's own interface, never another host's adapter for it (decision).
-
Implement it in
packages/oar/src/runtimes/<id>/.index.tsdeclares the runtime withdefineRuntime({ id, ... }), listing only the capabilities the runtime honestly supports: an absent capability is correct, a faked one is not. Thatindex.tsis also the public@botiverse/oar/runtimes/<id>entry, so it imports no SDK statically: load SDK-bound code on first call (onFirstCall,shared/lazy.ts) and add the id totests/runtime-entries.test.ts. The session adapter feeds acreateSessionKernel():- every native frame becomes exactly one
Framerecord viakernel.frame()(type,nativeverbatim,eventsholding theRuntimeEventBodys oar reads from it), never gated on turn state, never dropped, never synthesized; the sharedeventsOfderives the flatSession.events()surface from those records, so an adapter never builds it; - control calls go through
kernel.control()so request and response are records; - the process exit is an
exitedresponse; - a whole operation the runtime cannot do is a member the session lacks:
no
steeron a runtime that cannot inject, never asteerthat always rejects;capabilitiesdeclares queue durability, the attribution tier and image input as the runtime actually exposes them; - an option the runtime cannot honor is refused at open with an
UnsupportedOptionError, never dropped; one it refuses whenever given is declared inrefusedSessionOptionsand checked byrefuseSessionOptions, so the declaration and the refusal agree.
Keep the projection a pure fold (frame → commands) so replay tests (
tests/replay/, fixtures captured bypnpm sea-trial:record) can pin it. Keep parsing, compatibility policy and protocol details in that directory; reuseshared/mechanisms freely but never add runtime identity toshared/(source layout has the import rules). - every native frame becomes exactly one
-
Cover the whole runtime in the first version, not just sessions. Hosts need the account side on day one: installation and version (
installation,install/installPlan,checkUpdate/upgrade), sign-in (login,logout,authStatus,providers),listModels,accountUsage, and limit resets where the runtime has them. For each, find the runtime's own interface for it first (a CLI subcommand, a protocol method, a read-only slash command) and implement it in the same change. Where the runtime has none, leave the member absent or return its typedunsupportedwith the reason, and say on the runtime page what was checked, so the gap is a finding, not a deferral. A usage or rate limit the runtime reports on a turn is classified as a limit from the start (why). -
Register it in
src/index.ts, the only composition root: import, add to the built-indefaultRuntimesregistry, re-export. Give it aruntimeBrandsentry insrc/brands.tswith its SVG inpackages/oar/assets/brands/(attribution inNOTICE.md). A runtime that needs something the host installs (cursor's SDK) is not built in: export its constructor instead, and add it toallRuntimesinsea-trial/harness/runtimes.tsand to the CLI's registry inpackages/cli/src/runtimes.ts(capabilities). A runtime with host-owned execution/storage, such as pi-durable, stays out of the default and CLI registries; test it with its configured fixture. -
Make the behavior suite pass unchanged.
OAR_TEST=<id> pnpm sea-trialagainst your real local installation. The cases insea-trial/cases/are the contract: make the runtime pass them, don't loosen them to fit. A case that needs a capability skips on its absence (a session withoutsteer), never on a runtime name. -
Add an aimock backend if feasible (
sea-trial/harness/aimock.ts,backends.ts, and thebehaviororacp-behaviormatrix in.github/workflows/ci.yml) so the contract stays verifiable without a login, and add vendor tests for the provider-side specifics worth pinning. -
Update the docs in the same commit: the runtime lists in the root and
packages/oarREADMEs, a runtime page and its index entry, and the experiments README for any probes you added. The runtime page starts with the vendor's programming interface and concepts, then maps each capability to OAR or names the missing support and evidence.
- Locate the layer; that picks the test. Wrong behavior visible through the public Session API: a behavior case (if every runtime must honor the promise). A wrong request sent to the vendor, or a mishandled vendor-specific edge: a vendor test. A pure-logic mistake in a fold or resolver: a unit test.
- If the vendor's actual behavior is the open question, probe it first with an experiment and record the conclusion; don't derive the fix from documentation guesses.
- Write the failing test before the fix, at the cheapest layer that can
express it.
OAR_TEST=<runtime>-aimockreproduces most integration bugs without a login. - Fix at the right level. Runtime-specific policy belongs in
runtimes/<id>/; touchshared/only when the mistake is genuinely runtime-independent, and then runpnpm tsx sea-trial/all.ts, because every backend is affected. - Validate up the layers and finish with
pnpm run check. The reproducing test stays in the suite as the regression proof. - Update the runtime page when a bug changes the documented native behavior, OAR mapping or verification status. Distinguish the vendor's behavior from loss or policy introduced by OAR.
- Assert, don't
if + throw. Checks usenode:assert/strict(or vitestexpect), inside behavior cases and vendor tests too. Shared narrowing helpers (promptTurn,expectAvailable) live insea-trial/vendor/support/asserts.ts. - Snapshot value shapes. When asserting what a value looks like (an
outcome object, an event sequence, a fold result), pin the whole thing with
toMatchInlineSnapshot; no substring checks likereason.includes("400"). Snapshots update on local runs and are enforced whenCIis set (vitest.config.ts: updatenone). Keep plain assertions for logic and invariants (ordering, ranges, idempotence). - Test layer = assertion channel. What the public Session API shows goes in a behavior case that runs on every backend; anything that needs the scripted provider's view (request contents, error edges, vendor fingerprints) or a vendor-specific trigger goes in a vendor test.
- Failures must self-diagnose. Include the actual value in the assertion
message; long-running suites write traces and artifacts under
oar-trial-run/.
Both packages carry one version. A release is a pull request
(chore: release vX.Y.Z) that only sets version in
packages/oar/package.json and packages/cli/package.json; its body is the
release notes. Squash merge it, then start
.github/workflows/release.yml on main:
stamp workflow run release.yml --repo botiverse/oar -f version=X.Y.ZFrom the release commit's merge until its tag exists, do not merge a pull
request that changes .github/workflows/. A workflow change on main can
make GitHub reject the older commit's new tag as a workflow update, which
the release job's token cannot authorize. Cindy owns this hold and lifts
it once the tag is confirmed at the release commit.
Dispatch right after the release pull request merges: the workflow releases
the head of main only after ci.yml succeeded on that exact commit (a
docs-only head has no run and fails the gate). It refuses a version that
already has a tag or differs from either package version, runs
pnpm run check and pnpm run build, and publishes both packages with
pnpm -r publish, which rewrites the CLI's workspace:* dependency to the
real version. A separate release job, which runs no project code and is
the only one with write access, then creates the annotated vX.Y.Z tag and
the GitHub release, whose notes are the release pull request's body. For a
transient failure after publishing, "Re-run failed jobs" retries only the
failed release job. A workflow-permission refusal needs the recovery below;
retrying does not grant the missing permission. If ci.yml
flaked on the release commit, start a fresh run with
stamp workflow run ci.yml --repo botiverse/oar and dispatch again once it
passes. npm trusted
publishing (OIDC) authenticates the publish job; the repo holds no npm
token. Pushing a vX.Y.Z tag by hand still publishes that commit, without
the CI gate or the GitHub release.
If tag creation fails with without workflows permission, keep the original
run's commit as the release target. This can happen when main gains workflow
changes while the release waits for CI or publishes: the old commit's
workflow files now differ from main. It does not mean every release that
contains a workflow change will fail. The
0.108.1 investigation
records this ordering and the successful recovery.
Changing the command to gh release create --target <sha> does not solve
the permission problem: GitHub documents the same workflow-write requirement
for automatic tag creation by the releases API,
which GITHUB_TOKEN cannot acquire. The
create-reference API
also lists workflow-write permission; it is not a verified bypass. Keep
publication on the original commit rather than tagging a newer main or
adding a more privileged credential to the job.
Only Cindy performs this recovery:
-
Save the failed run and its full commit SHA. Confirm the
publishjob succeeded and both exact package versions are present on the public npm registry (npm view @botiverse/oar@X.Y.Z versionandnpm view @botiverse/oar-cli@X.Y.Z version, both with--registry=https://registry.npmjs.org). Verify those names and versions against the manifests at the original commit. Missing or inconsistent publication evidence needs investigation, not this recovery. -
Read the remote tag, including the peeled commit for an annotated tag:
git ls-remote origin refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}'. If it already points to the original commit, skip tag creation. If it points elsewhere, stop; do not move or force-push it. -
Only if the remote tag is absent, create it at the verified original commit and push through Stamp from a checkout configured with
stamp repo use .. Replace the placeholders before running:git tag -a vX.Y.Z ORIGINAL_RELEASE_COMMIT_SHA -m vX.Y.Z stamp push origin refs/tags/vX.Y.Z
An existing local tag must be checked against that commit too; do not overwrite it. Check Stamp's publication receipt and re-read the remote tag before continuing. Stamp has a different authorization path from the workflow's
GITHUB_TOKEN; if it refuses the push, keep the failure and resolve that permission with the repository owner. -
An external tag push triggers the legacy tag-push publish workflow. Record and inspect that run before dispatching recovery. It is not suppressed like a push made with
GITHUB_TOKEN: 0.108.1 reran the publish command, which found no new packages. Do not promise that a manual tag push runs only the release job, and do not start another normal publish dispatch or rerun all jobs after npm publication succeeded.
If npm publication succeeded and the correct tag already exists but the
GitHub release is missing, dispatch the recovery path explicitly from main
(also when recovering an older version; do not use the tag as --ref):
stamp workflow run release.yml --repo botiverse/oar --ref main -f version=X.Y.Z -f release_only=trueRecovery does not publish packages, create or move tags, run package code,
or require the historical version to remain npm's latest. It requires
both exact package versions on the public npm registry, matching package
names and versions in the tagged commit, and exactly one merged release
pull request for that commit. It takes notes from that pull request rather
than current main, verifies the tag has not moved, and uses
gh release create --verify-tag.
Missing or inconsistent evidence, API errors and an already existing GitHub
release fail the recovery. Normal publication keeps its existing gates.