release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke - #623

Closed
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration
Closed

release: v0.10.0-rc15 train — auto-repin CI + sell resume + flow gates + paid-MCP smoke#623
bussyjd wants to merge 14 commits into
mainfrom
release/rc15-integration

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Release train for v0.10.0-rc15

Bundles the four ready, individually-green PRs into one reviewable merge so main lands them together ahead of cutting v0.10.0-rc15. Each child PR's CI already passed; this branch is origin/main + four signed merge: commits, full go test ./... green (34/34 packages), go build ./... clean.

PRTitleSurface
#618auto-repin embedded x402 image pins + release freshness gateCI: docker-publish-x402.yml repin job + release.ymlverify-image-pins gate
#619obol sell resume so offers recover after a host rebootcmd/obol/sell.go, sell_agent.go, persisted ledger
#621flow-16 unsatisfiable Ready gate; flow-04 first-inference windowflows/ smoke gates
#622flow-17 end-to-end paid MCP (obol sell mcp) smokeflows/flow-17-sell-mcp.sh, flows/clients/mcp-paid-client.go

Why a train PR

main now requires a reviewed PR (direct push is blocked by branch protection). Merging this one PR lands all four with a single review and preserves each PR's per-merge: provenance in history. The four child PRs (#618/#619/#621/#622) are superseded by this and can be closed once this merges.

Sequencing after merge

  1. The merge triggers ci(release): auto-repin embedded x402 image pins + release freshness gate #618's new repin-embedded-pins job → it rebuilds the x402 images from this main and commits chore(ci): repin x402 images to <sha> [auto] (GitHub-signed).
  2. Baseline release-smoke run against the wopus ollama model → RELEASE_REPORT.md.
  3. Tag v0.10.0-rc15release.ymlverify-image-pins gate confirms pins are fresh → binaries + draft release.
  4. Notes rewritten from .github/release-template.md with the smoke table; published as pre-release.

All commits signed (%G?=G).

bussyjdand others added 14 commits June 10, 2026 19:04
After a host reboot Docker's restart policy brings the k3d cluster back
without a `stack up`, so resumeSellOffers (previously only reachable
from the stack-up action) never runs: persisted sell-inference offers
survive in etcd but their host gateways are gone, every offer sits at
UpstreamHealthy=False, and the public catalog (/api/services.json)
serves []. Observed live on the rc14 prod seller after a reboot.
- expose the existing resume path as `obol sell resume` (idempotent:
live-PID gateways are skipped, kubectl applies re-assert)
- `--install-boot-unit` writes + enables a systemd user unit on Linux
so resume runs automatically at boot (lingering hint printed)
- refresh the stale resumeSellOffers doc comment (it claimed gateways
are not restarted; startDetachedInferenceGateway has done so since
the resume feature shipped)
Live reboot test on the seller box surfaced two ways the relaunched
gateway dies instantly, leaving the offer at UpstreamHealthy=False:
1. Binary skew: startDetachedInferenceGateway preferred the installed
BinDir obol over the binary running `sell resume`. The arg-builder
encodes the running version's flag surface, so an older installed
CLI (rc11 predates the --description spelling) rejects the args
with "flag provided but not defined". Relaunch now spawns the
running executable (resumeGatewayBinary), and the arg-builder emits
--register-description — the one spelling every released CLI
parses — as belt-and-braces for the BinDir fallback.
2. Validation drift: the slash-in-model rule (added after existing
descriptors were persisted) rejected the replayed model name, so
pre-rule offers could never resume under a new binary either. The
spawned gateway now carries OBOL_SELL_RESUME_REPLAY=1 and the rule
downgrades to a warning for replays; new offers still hard-fail.
Tests: TestResumeGatewayBinaryPrefersRunningExecutable,
TestResumeGatewayEnviron, TestValidateSellInferenceModelName, and
TestBuildResumeGatewayArgs now pins --register-description and bans
the rc12+ spelling.
Two more gaps found prepping the unattended reboot test:
- installResumeBootUnit pinned BinDir/obol into ExecStart. On a box
whose installed CLI predates `sell resume` (rc11 on the live seller),
the unit fails on every boot. Pin the binary running the install
command instead (it just proved it has the subcommand) and print the
pinned path so the operator knows to re-install after moving it.
- At boot the k3d API server lags Docker by a minute or more, and
resumeOneInferenceOffer's kubectl applies run BEFORE the gateway
relaunch and warn-and-continue — a too-early resume silently resumed
nothing and nothing retried. `sell resume` now waits for /readyz
(3min cap) before replaying offers; `stack up` is unaffected.
Tests: TestWaitForClusterAPI (nil fast-path without kubeconfig, error
after deadline for unreachable cluster).
Third live-reboot finding: the unit ran at boot, resume reported
"Gateway started in background", and the gateway was dead anyway with
an empty log. setsid detaches the session but NOT the cgroup — the
relaunched gateway lives in the unit's cgroup, and when a plain
Type=oneshot unit deactivates, systemd kills every process left in it.
RemainAfterExit=yes keeps the unit active (exited) after ExecStart,
which preserves the cgroup and the gateway with it. Side effect worth
having: `systemctl --user stop obol-sell-resume` is now a deliberate
way to take the gateways down.
TestRenderResumeBootUnit pins the new directive.
…n freshness
Every docker-publish-x402 branch build now lands a pin-bump commit
(repin-embedded-pins job) updating the embedded x402-verifier /
serviceoffer-controller / x402-buyer references to the images just built,
and the release workflow gains a verify-image-pins gate that fails the tag
when any source in the binaries' live import graph (go list -deps) changed
after the pinned build commit. Together they make the rc14 stale-pin trap
— a release whose embedded pins predate its own payment-path changes —
structurally impossible: the bump is automatic, and a tag cut before the
bump lands cannot release.
The bump is committed through the GraphQL createCommitOnBranch API, so
the commit is signed by GitHub itself (verified, github-actions bot) —
compatible with the repo ruleset rejecting unsigned commits, which a
workflow git push could never satisfy. expectedHeadOid is the live
remote head with one retry on race; only the two guarded template files
are ever sent.
The gate is fail-closed (a go-list failure refuses to pass rather than
degrade to a partial path set), binds each embedded digest to what GHCR
serves for the pinned tag (a fresh tag with a hand-edited digest fails),
ignores _test.go/testdata churn, and hunk-filters the two pin-carrying
templates so pin bumps don't self-stale while any other edit to them
still counts. release/** branches get the same build+repin treatment as
main.
The exact-ref pin tests become invariant tests: pins must be digest-
pinned, share one build commit, and descend from the named fix commits
(ancestry-verified via git, skipped on shallow clones) — so the bot can
bump pins without touching Go files while the carries-fix-X guarantees
get stronger.
…edger
Generalizes the resume path per review feedback: the sell-http store
becomes the persisted-ServiceOffer ledger (dir name kept for files
written by shipped CLIs) and every offer type without a host process
persists into it — sell http, sell agent (both creation sites), the
agent-backed demo (offer only: replaying an Agent CR would mint a fresh
wallet and orphan funds on the old one), and the legacy demo as a v1
List bundle (namespace + backend Deployment + Service + offer) so
resume restores a working demo rather than an offer with a missing
upstream. sell mcp has no ServiceOffer (foreground server) and is
documented as not resumed.
resumeSellOffers simplifies to one kubeconfig guard + two phases:
inference store (cluster artifacts + detached gateway relaunch), then a
single ledger walk with type-aware messaging via a pure, tested loader.
Two lifecycle holes closed while building the coverage matrix:
- obol agent delete now drops the agent namespace's ledger entries —
otherwise every later resume replays ghost offers for a deleted agent.
- obol sell update now refreshes the ledger from the live post-patch CR
(List-bundle aware) — otherwise the next resume kubectl-applies the
OLD payTo/price back, silently reverting an intentional payment
change. Update also adopts offers created outside the CLI.
New tests: mixed-type ledger walk, demo List-bundle parsing,
namespace-scoped removal round-trip, and source-scope guards on every
persist/refresh/cleanup site.
Three confirmed majors, all reproduced against a live cluster:
- agent delete left the agent's ServiceOffers ALIVE in etcd (the agent
finalizer tears down children but leaves the namespace and offers)
while sweeping their ledger entries — and a surviving offer
reconciles back to Ready, paying the deleted agent's wallet, if the
name is ever reused. deleteCRDAgent now deletes the namespace's
ServiceOffer CRs (the offer finalizer handles route/registration
teardown) and the ledger sweep moves into the cluster-reachable
branch, since an unreachable cluster means the CRs survive and the
ledger must keep covering them.
- sell stop never refreshed the ledger, so an etcd-wiping
stack-down/up replayed the pre-drain manifest and resurrected a
deliberately stopped offer fully live (reboot-resume was already
safe: client-side apply never owned drainAt). The drain patch now
refreshes the ledger like sell update does.
- agent-offer replay failed outright after a stack recreation: the
bare manifest's namespace no longer exists and kubectl apply errors
with 'namespaces not found' — the documented 'offer waits on the
missing agent' behavior never happened. Both agent persist sites now
store a v1 List bundling the agent NAMESPACE (canonical labels) with
the offer; the Agent CR stays excluded so no fresh wallet is minted.
Plus the inference half of delete=>no-resume (minor): sell delete now
tombstones the inference descriptor (DeletedAt; kept for list/status
history, cleared by re-creating the offer) and resume filters
tombstoned descriptors via activeInferenceDeployments. Misleading
sell-http wording in delete output and the false 'CRs died with the
namespace' comments corrected; new tests pin every behavior (bundle
round-trip incl. no-Agent-CR assertion, stop/delete scope guards,
tombstone filter, branch placement of the agent-delete sweep).
…ference probe
Both release-smoke failures on the rc14 wopus run reduce to these two
flow bugs — no stack defect (reproduced live, full chain diagnosed):
- flow-16 §2.2 polled Ready=True for an offer created WITH registration
enabled and no `obol sell register` submitted, which the controller
keeps Ready=False / AwaitingExternalRegistration by design ('offer
already serves paid traffic') — the gate could never pass as written,
and only ever matched historically because 'Ready=True' substring-
matched 'PaymentGateReady=True' when the ladder converged in time.
Gate now polls the serving condition set (UpstreamHealthy +
PaymentGateReady + RoutePublished, anchored greps) over 300s, which is
exactly what §3's 402 probe exercises.
- flow-04 step 12 used `curl -sf --max-time 120`: too tight for the
FIRST inference ever routed through the Hermes agent pipeline on a
local Ollama model (the multi-thousand-token system prompt pays full
prompt processing before the KV cache warms; ~150s observed for a 27B
on an M-series host), and -f swallowed every diagnostic so the fail
message was empty. Now 300s, no -f, and the fail message carries the
HTTP status + body snippet.
Verified against a live cluster in the failing state: the new flow-16
gate passes where the old one cannot; the flow-04 call returns 200 with
correct content once warm.
Adds the only e2e coverage of `obol sell mcp` (paid MCP tool over x402
in-band _meta; shipped in rc14 with unit tests only). A reusable SDK
client (flows/clients/mcp-paid-client.go, the x402-foundation MCP client)
drives the full loop against a foreground `obol sell mcp` server backed
by a mock upstream, reusing flow-10's anvil fork + facilitator:
free ping → requirements surfaced in _meta (payTo/asset/network/amount)
→ unpaid call rejected → auto-paid call (EIP-3009 in _meta) settles
on-chain → seller API key injected upstream + invisible to the buyer
→ buyer balance delta == price.
Registered in release-smoke.sh after flow-16 (needs no cluster of its
own; runs while anvil + facilitator are up).
Two fork-specific footguns found and documented (CLAUDE.md pitfalls
17/18), both reproduced live:
- EIP-7702-contaminated test accounts: anvil/hardhat accounts #1-#9
carry 0xef0100 delegation code from real-chain 7702 experiments on
Base Sepolia. FiatTokenV2_2 verifies EIP-3009 via SignatureChecker,
which routes any code-bearing `from` to EIP-1271 and rejects an
otherwise-valid ECDSA signature ('invalid signature' → facilitator
503). The buyer MUST be a fresh EOA — flow-17 generates fresh keys and
preflights `cast code == 0x`. (Same reason flow-08 uses the agent's
generated wallet.)
- x402 SDK signs validAfter=now with no past buffer; a long-lived anvil
fork's block.timestamp lags real time → 'authorization is not yet
valid'. flow-17 syncs the fork clock (evm_setNextBlockTimestamp+mine)
before the paid call.
Automated by docker-publish-x402/repin-embedded-pins after the image build at 926620b. Committed via the GitHub API so the commit is verified.
@OisinKyne

Copy link
Copy Markdown
Contributor

Merged in #624

@OisinKyne
OisinKyne deleted the release/rc15-integration branch June 11, 2026 17:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bussyjd@OisinKyne