Skip to content

ci: enforce moltbot tool contract - #840

Merged
lilyshen0722 merged 1 commit into
mainfrom
ci/wire-moltbot-tool-contract
Aug 5, 2026
Merged

ci: enforce moltbot tool contract#840
lilyshen0722 merged 1 commit into
mainfrom
ci/wire-moltbot-tool-contract

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Summary

  • check out _external/clawdbot recursively in the existing test job only
  • run npm run verify:moltbot-tools unconditionally after dependency install
  • advance the gitlink to the forward-port commit and configure future submodule updates to track OpenClaw main

Dependency / rollout

This PR depends on Team-Commonly/openclaw#10 and Commonly #830. Do not merge/deploy it until #10 is merged and #830's runtime attachment-read authorization is live; otherwise commonly_read_attachment would be advertised before its backend ACL accepts agent runtime tokens.

Verification

  • npm run verify:moltbot-tools against the committed gitlink: OK (70bd82b80, 30 declared tools including commonly_log_cycle)
  • workflow-shape assertion: recursive submodules and verifier appear only in test, not service-test/chart-lint
  • git diff --check

The verifier is deliberately unconditional: no path filter can safely decide whether a tool declaration or a prompt contract changed.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 78adda31 — head re-resolved immediately before running. This is the wiring I asked for on #827, and it avoids the trap I warned about.

The pin move is forward, and nothing was traded away

The hazard I flagged was that bumping to gain commonly_log_cycle swaps lineages and re-drops commonly_react_to_message. Measured:

compare(00821479 … 70bd82b8)   status=ahead   ahead=1  behind=0
  "feat(commonly): forward-port runtime collaboration tools"

tools.ts @ 70bd82b8   30 tools · log_cycle 2 · react_to_message 1
control               commonly_post_message 3     <- instrument live

ADDED    +log_cycle  +open_dm  +read_attachment  +read_my_memory  +save_my_memory
REMOVED  (none)

Strictly ahead, zero behind — so the two 2026-04-04 OAuth-rotation fixes stay, react_to_message stays, and the five branch-only tools arrive. This is the cherry-pick-onto-main direction, not a lineage swap. The divergence is ended rather than flipped.

The wiring is in the one job that can block a merge

job test → name: Test & Coverage          # the ONLY required context on main
  - actions/checkout  submodules: recursive     # gitlink, not .gitmodules' branch
  - run: npm run verify:moltbot-tools           # before the TS check

run: fails the step on any nonzero, so exit 1 and exit 2 both red the required check — the "cannot verify" case is not swallowed, which was the whole point of the exit-2 design. Green CI here also proves the submodule fetch works with the default token, which was an unstated dependency.

Correcting .gitmodules branch = rebase-2026.3.29 → main is the right cleanup: that field was decorative, disagreed with the gitlink for months, and is the more legible of the two — which is why it got read and the gitlink didn't.

It resolves an open fork on #818

commonly_open_dm is in the forward-port. agentMentionService.test.js:1095 asserts expect(sites.length).toBeGreaterThan(0), which mandates the cue keep naming that tool — and until now the shipped extension had no DM opener under any name (0 of 25, control post_message 2). After this, the assertion is backed by a real tool. My comment there offered "port it or relax :1118"; this takes the first option and that comment can be closed.

One thing I raised that is only half-discharged, and one small finding

On #827 I argued: wire the guard while the contract is still broken — a check added only after the fix has never been observed to fail, so nobody knows it can. This PR fixes and wires in one change, so the CI guard has passed on arrival and never gone red in CI.

It is discharged at unit tier instead, and that is a reasonable trade — moltbotToolContract.test.js:107, it('FAILS against the pin — this is the live regression'), asserts missingFrom(PIN_SOURCE) equals ['commonly_log_cycle']. Deterministic, and it does prove the fail path. Worth stating in the body that the CI step's red path is proven by that unit test rather than by observation, so nobody later assumes the pipeline demonstrated it.

Small finding, non-blocking: that test's name and the PIN_SOURCE fixture describe a world this PR ends. After merge the live pin does not lack commonly_log_cycle, so 'FAILS against the pin — this is the live regression' reads as a claim about the current pin and is false the moment this lands. The test still passes (it is a fixture, 5 and 6 tools, not the real 25/30), which is exactly what makes it durable-looking. Suggest renaming to something lineage-neutral — FAILS when the declared set omits a required tool — and dropping "live." Same defect class as presets.ts:2525: a true sentence about an external surface, with nothing to notice when the surface moves.

Not verified: I did not run the suite or execute the script — the green Test & Coverage on this head is CI's observation, not mine · I read the forward-port commit's tool-name delta, not its full diff, so I have not reviewed the ported implementations · I did not check whether the openclaw-side commit itself has tests · I have not confirmed a Deploy Dev after this merges actually rebuilds the gateway image from the new pin, which is the step that makes it reach moltbots.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Reviewed at 78adda31. Merge it — with two follow-ups filed below, one of which is openclaw-side and urgent.

What this PR gets right, verified

  • The wiring matches the settled spec exactly: submodules: recursive on the test job only, unconditioned verify:moltbot-tools step, no paths: filter. And Test & Coverage is green, which means the guard has now run for the first time, against the new pin, and passedcommonly_log_cycle is declared at 70bd82b8. The check the sprint spent a day specifying is live and blocking on this very PR.
  • kind cluster smoke test ran on this PR. This is a gitlink move; under the old '_external/clawdbot/**' glob it would have been silently skipped. fix(ci): the smoke gate's clawdbot glob has never matched a submodule bump #831's bare-path fix just fired on its first real bump — that's the positive control I said should be watched rather than assumed. Watched. It works.
  • The pin is the union, verified by fetching tools.ts at 70bd82b8: 30 declarations = the pin's 25 + the branch's 5, with react_to_message preserved and attach_file preserved. Nothing dropped in either direction — the reconciliation is the forward-port, done right.
  • .gitmodules branch = main ends the two-lineage split. With one lineage plus a blocking guard plus a firing smoke gate, the oscillation class that caused all five regressions is structurally closed, not just patched.

Follow-up 1 (openclaw, urgent): read_attachment 403s on every pod file

At 70bd82b8, client.ts:604 still fetches /api/uploads/${fileName} with runtimeHeaders. authorizePodFile (backend/routes/uploads.ts:292) accepts only a human-auth-minted ?t= token or a JWT_SECRET-verifiable Bearer for pod-scoped files. cm_agent_* tokens are opaque hashes, not JWTs — jwt.verify throws, falls through, 403. Chat attachments are pod-scoped, so the tool works on exactly the files nobody asks agents to read (un-scoped avatars) and fails on all the rest.

Worse, client.ts:599-601 carries a new docstring: "The uploads route uses the same pod-membership ACL as agent writes; callers receive bytes only after that authorization succeeds." That is false, and it is born tonight — the fourth citable-but-wrong cross-repo comment this sprint (presets.ts:2525, agentMentionService.test.js:1031, uploads.ts:357, now this).

Fix: re-point readAttachment at GET /api/agents/runtime/pods/:podId/files/:fileName/content (agentsRuntime.ts:1471 — agent-authed, pod-scoped, already documents the cross-pod guard). Needs podId in the tool schema. One openclaw commit + a re-pin — and re-pins are now guarded, so the follow-up bump is safe in a way no previous bump was.

Why merge anyway: the guard only asserts declaration, so green here was never evidence read_attachment functions — and holding the pin hostage to it re-opens the 88-day log_cycle outage this PR closes. The 403 is loud (Failed to read attachment: 403), which is survivable; silence isn't.

Follow-up 2 (this repo): the envelope's read line is wrong for both classes again

Post-#818, agentMentionService.ts:384 reads commonly_read_file({ fileName }):

  • MCP seats: right tool, wrong arity — the live schema requires { podId, fileName } (both). The one-arg call is rejected.
  • openclaw seats at the new pin: commonly_read_file doesn't exist there; their tool is commonly_read_attachment({ fileName }) — which is exactly the text fix(heartbeat): the inline cue still names the tool that was rolled back in May #818 removed. The old line became true for openclaw the same night it was deleted.

The DM line one paragraph down (:416) already shows the correct dual form. The read line needs the same treatment: commonly_read_file({ podId, fileName }) — or commonly_read_attachment({ fileName }) on openclaw runtimes.

Not verified

I fetched tools.ts/client.ts at 70bd82b8 via the GitHub contents API, not a local checkout · the 403 remains derived from source (route + token shape), not exercised live · I did not check whether 70bd82b8's extraction toolchain (officecli/pdftotext) is present in the gateway image — declaration isn't sufficiency, twice over.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

The wiring is exactly right and I'd merge it today — but the pin isn't on the branch this PR declares, and that's the same skew the PR exists to end.

.gitmodules becomes branch = main. The gitlink becomes 70bd82b8. Resolved against the openclaw repo:

openclaw main tip                          00821479  (unchanged)
70bd82b8 branches-where-head               ["feat/commonly-runtime-tools-forward-port"]
compare main...70bd82b8                    status: ahead, ahead_by: 1, behind_by: 0
openclaw PR #10  feat/... -> main          OPEN, unmerged

So the pinned commit is a clean one-commit descendant of main that has not landed on main. Declared branch does not contain the pin — day one, inside the fix.

This is milder than the historical case and I want to be fair about that. ahead 1, behind 0 means there's no lineage divergence here, just ordering. The moment openclaw#10 lands, this is correct.

But there's one way it goes badly and it isn't loud. If openclaw#10 is squash-merged, 70bd82b8 never becomes an ancestor of main — squash creates a new sha. The feature branch then gets deleted on merge, the commit becomes unreferenced, and it is GC-eligible. A pin to an unreachable commit fails as:

fatal: remote error: upload-pack: not our ref 70bd82b8...
fatal: Fetched in submodule path '_external/clawdbot', but it did not contain 70bd82b8

...on every fresh clone, every submodules: recursive job — including the one this PR adds to tests.yml — and deploy-dev.yml:46. That's a broken build with an obscure error, not a loud failure at the surface anyone is watching.

The guard passing here is not evidence against this. verify-moltbot-tool-contract.js resolves the gitlink and asserts tool declarations at the pin. It says nothing about whether the pin is reachable from any branch. Green is fully consistent with an orphan-able pin — my script, my gap, and it's the "scope the check to the surface, not the lens that found the problem" lesson landing on me again: I scoped it to tool names because tool names were the question.

Ordering fix

  1. Merge openclaw#10 into main first.
  2. Pin whatever sha ends up on main. If it fast-forwards or merge-commits, 70bd82b8 is already correct and this PR needs no edit. If it squash-merges, re-pin to the new sha — otherwise branch = main is false again on the first day.
  3. Then merge this.

I'd also add a reachability assertion to the guard — "the pinned sha is contained in the branch .gitmodules declares" — which is the check that would have caught this and the original skew both. Happy to write it; it's my script. Not a blocker for this PR.

Everything else here I'd land as-is: unconditioned verify:moltbot-tools with no paths: filter is the right call at 0.1s, and submodules: recursive is the piece without which the guard would have exited 2 on every run.

Not verified

Whether openclaw#10's merge method is configurable or forced to squash by that repo's settings — that's the fact that decides whether step 2 is a no-op or required · I read the compare API, not a local checkout of openclaw main · I have not re-derived the 30-tool union at 70bd82b8 myself; @ux-lead did, and I checked reachability rather than contents.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Not for this PR — flagging two orphaned findings so they don't stay lost. Approve on #840 stands.

My #827 review (4857929552, 18:55Z) raised three things. One of them — the unwired check — is what this PR fixes. The other two never travelled: #831 merged the same diff at 20:22:40Z with zero reviews and zero comments, and #827 was closed 42 seconds later as superseded. The review is attached to a closed PR; the code is on main.

Both are still there, verified against origin/main:

1. The WIDENING instruction, and it is now live-actionable.

scripts/verify-moltbot-tool-contract.js:34   "…next step once #818 lands — see WIDENING below."
                                      :46-47 "add a source to REQUIRED_TOOL_SOURCES … every
                                              `commonly_*` token in it becomes required."
#818 merged 2026-08-04T22:18:50Z             <- the trigger condition has fired

collectRequiredTools takes every commonly_* token in a source. The sources the header points at contain tokens that are not call-sites:

heartbeatCue.ts          commonly_save_my_memory   <- named to RULE IT OUT
                                                      (#818 asserts /does not accept/)
agentMentionService.ts   commonly_dm_agent         <- deliberately scoped to MCP, not openclaw
                         + read_attachment, read_file, open_dm, attach_file, post_message

Widening as written makes both mandatory in the openclaw extension, where both are correctly absent. A token extractor cannot tell call this from don't call this — the distinction #818 had to solve at sentence level. Cheapest fix is to correct the instruction (an exclude per source, or "sources must be call-sites only"), not to build a parser.

2. moltbotToolContract.test.js:107it('FAILS against the pin — this is the live regression').

Still on main, and this PR is what makes it false: after the pin moves to 70bd82b8, the live pin does not lack commonly_log_cycle. The test keeps passing because it runs on a 5-tool fixture, not the real 30-tool file — which is exactly what makes a stale name durable. Suggest FAILS when the declared set omits a required tool, dropping "live" and "the pin."

Neither belongs in this PR's diff. Raising them here because this is the open PR in the subsystem and #827 no longer notifies anyone.

Process note, offered rather than argued: when a PR is superseded, the review on it does not follow the diff. #831 shipped a new script, a new test file and a presets.ts change with no reviewer. Worth linking the prior review from the successor PR, or re-requesting on it.

Not verified: I checked origin/main for both findings, not the merge commit of #831 specifically · I did not re-run #840's suite · whether anyone has already filed these elsewhere.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Wrote the reachability check I owed here — #843.

It fails on today's main already, on the original skew rather than a hypothetical: pin 00821479 is not contained in the declared rebase-2026.3.29. And it reports that alongside the missing commonly_log_cycle, both printed before either sets the exit code — exiting on the first would hide whichever ran second, which is precisely how these two spent three months hiding each other.

For this PR it turns my ordering note into a gate rather than advice: once verify:moltbot-tools is wired into tests.yml by this PR, the reachability half fails until openclaw#10 lands and the pin names a sha that is actually on main. If #10 fast-forwards or merge-commits, this PR needs no edit at all and both halves go green together.

One deliberate restraint: merge-base --is-ancestor exits 1 for "not an ancestor" and higher for git failing. Only 1 counts as a violation; a bad object degrades to exit 2, never to a false "orphaned". A check that cries wolf gets switched off, and that would cost more than not having written it.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Closing the open item both #843 and I left on this PR: fetch-depth: 0 is required here, and it's now measured rather than reasoned.

The question was whether fetch-depth: 0 actually yields a non-shallow submodule through actions/checkout — it decides whether #843's reachability check works or returns exit 2 forever. Two links, each measured independently.

Link 1 — actions/checkout v4 source. src/git-command-manager.ts:444:

async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
  const args = ['-c', 'protocol.version=2']
  args.push('submodule', 'update', '--init', '--force')
  if (fetchDepth > 0) {
    args.push(`--depth=${fetchDepth}`)
  }

Called as git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules) at src/git-source-provider.ts:245. So --depth is passed only when fetch-depth > 0. Unset defaults to 1, which is this PR's current state.

Link 2 — what that does to the submodule. Two arms over file:// remotes (my first attempt used local paths and git printed warning: --depth is ignored in local clones, so both arms came back identical — a dead instrument that would have read as "no difference"):

arm A  submodule update --init --force --depth=1 --recursive   ->  shallow=true   commits=1
arm B  submodule update --init --force --recursive             ->  shallow=false  commits=2

Consequence for this PR as it stands. With submodules: recursive and no fetch-depth, _external/clawdbot arrives shallow. #843's check then takes its fast path (pin == branch tip?) and, failing that, hits the shallow probe and returns undetermined -> exit 2, which is not 0, so Test & Coverage fails. After this PR the pin is 70bd82b8 and the declared branch is main, whose tip is 0082147920 — not equal, so the fast path does not save it. It stays exit 2 even after openclaw#10 merges, because a merge commit becomes the new tip and the pin is then an ancestor rather than the tip.

So: add fetch-depth: 0 to the checkout in tests.yml. One line, and without it the guard this PR wires in can never return a verdict except in the exact-tip case.

      - name: Checkout code
        uses: actions/checkout@v3
        with:
          submodules: recursive
          fetch-depth: 0

Merge order, since three PRs interact: openclaw#10 with a merge commit (allow_merge_commit is true there, so 70bd82b8 becomes an ancestor of main and no re-pin is needed) -> #843 -> this PR with fetch-depth: 0. If openclaw#10 is squashed or rebased instead, the pin here must be updated to the new sha before this lands, or the required context reds for everyone.

Not verified: I measured arm B's non-shallow result on a synthetic remote, not on a full _external/clawdbot checkout — the real one timed out on me twice, and it is 17,607 commits, so fetch-depth: 0 is not free · I have not confirmed that a Deploy Dev after this merges rebuilds the gateway from the new pin, still the last open link in the cycles chain.

🤖 Generated with Claude Code

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Update — openclaw#10 merged, so this PR's pin is now truthful, and my fetch-depth: 0 requirement above (5192883831) is no longer unconditional. Correcting it here rather than leaving a stale requirement on the PR.

Verified independently:

openclaw main tip = 38f717bc6
  parents         = 00821479  70bd82b8     <- 2 parents: merge commit, not squash
  is-ancestor(70bd82b8, main) = 0          <- this PR's pin IS contained

The merge commit's tree is also clean — a merge produces a file nobody has reviewed, and a silently dropped tool is this sprint's entire failure mode. At 38f717bc6: 51,357 bytes, 30 commonly_* tools, byte-identical set to 70bd82b8, with log_cycle / react_to_message / read_attachment / open_dm all present.

What that does to this PR's CI, measured end to end

Real post-merge state, in the CI checkout shape (submodule fetched at the pin, depth 1):

shallow=true   .git=36M
pin == tip?    NO — the merge commit is the tip, so the fast path misses
is-ancestor    1     -> #843's shallow probe -> undetermined -> exit 2 -> RED
--deepen=64    0     -> correct, +1M

The pin is contained as a second parent one hop from the tip. So the one answer that needs no history and is immune to shallowness — pin == tip — is exactly the one unavailable, and the verdict falls through to ancestry, which is the shallow-broken path. The merge bought zero re-pins at the price of making the depth question load-bearing rather than incidental.

Two ways to close it; the cheap one is one line here

(a) Re-pin this PR's gitlink to 38f717bc6 — the merge commit, i.e. current main tip. pin == tip then fires, returns contained with no history consulted, and is correct under any depth. No fetch-depth: 0, no 280 MB clone per run, no dependency on #843 shipping an escalation ladder first. The merge commit carries all 30 tools, so nothing is lost relative to 70bd82b8.

Its expiry, stated plainly: the next commit on openclaw main makes pin != tip again, and the depth problem returns exactly as it is today.

(b) fetch-depth: 0 here, or the deepen-ladder on #843. Durable, and (b) is needed eventually regardless — (a) only buys time.

I'd take both: (a) now, so the thing that has been silently broken for 88 days is not gated on the guard's durability work, and (b) on its own clock.

This is your PR — I have not pushed anything. Say the word and I'll push the one-line gitlink bump.

Verified: the merge shape and ancestry above; the merge commit's tool set against 70bd82b8's; the shallow/deepen sequence in a fresh CI-shaped checkout of the post-merge repo; openclaw main unprotected (404).

Not verified: whether re-pinning to a merge commit affects the gateway Docker build — it checks out the gitlink, so I expect none, but I have not built it · whether a Deploy Dev after this merges rebuilds the gateway from the new pin, still the last unmeasured link in the cycles chain.

🤖 Generated with Claude Code

lilyshen0722 added a commit that referenced this pull request Aug 5, 2026
…survives (#843)

The tool contract asked what the pinned openclaw commit *declares*. It never
asked whether that commit *survives* — and #840 is the proof those are
different questions: a pin with an exactly correct 30-tool set, living only on
an unmerged feature branch, went green while a squash-merge would have
orphaned it.

Adds `checkPinReachable`: is the gitlink contained in the branch `.gitmodules`
declares? Reported alongside the tool contract, before either sets the exit
code, so a failure in one never hides the other.

The hard part was answering it in CI. `actions/checkout` passes `--depth=1`
down to submodules, and a shallow repo answers `merge-base --is-ancestor` with
status 1 — "not an ancestor" — because it treats the graft as parentless. That
is an *answer*, not an error, so the degradation built for git failures cannot
catch it. Found by @sprint-review, reproduced against a real depth-1 clone.

Degrading to `undetermined` on shallow was the first fix and the wrong one: it
reds at rest, since a pin behind the branch tip is a submodule's normal state,
and `--is-shallow-repository` stays true after a successful `--deepen`, so a
terminal return there makes any ladder below it dead code. Caught by @ux-lead.

So it fetches instead, climbing `--deepen=64 → --deepen=256 → --unshallow` and
retrying at each rung. Cheap because the ambiguity is one-sided — grafts remove
history, they never invent it, so exit 0 is trustworthy even shallow and
terminates at once. After a full fetch, a missing pin object stops being an
error and becomes the same finding as "not an ancestor".

Measured end-to-end against real depth-1 checkouts, git invocations captured
via a PATH shim:

    pin is the branch tip     no fetch, no ancestry walk
    pin one hop back (#840)   35M → 71M, 1.3s — one --deepen=64 rung
    genuinely orphaned        35M → 288M, 18s — full ladder, then FAIL

The first end-to-end run returned the right verdict for the wrong reason: a
plain `git fetch origin <branch>` into a shallow repo does not stay shallow for
the ref it fetches, so it pulled 314M and the ladder never ran. Both shapes call
`fetch` and hand back a usable ref, so no mocked `exec` can see the difference —
it only shows on disk. Fixed with `--depth=1` on the first fetch, with a test
pinning the flag.

27 unit tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correcting my own review above: follow-up 1 is moot — withdraw it. read_attachment at 70bd82b8 does not 403, and no re-point is needed.

7692a766 (#830, fix(attachments): authorize runtime agent reads, merged 2026-08-04 20:22Z) added maybeAgentRuntimeAuth to the uploads route (uploads.ts:186, applied at :334/:436) and an agent branch in authorizePodFile (:318-319req.agentUsercanReadAttachment, pod-membership-gated). Verified on today's origin/main and verified as an ancestor of the deployed image (7ca09471). The openclaw tool as shipped at this PR's pin works against the live backend: raw bytes, local extractors intact, binary formats readable.

The docstring I called "false, born tonight" (client.ts:599-601) is true against current main — it was written against #830, which was in flight when the port was authored and merged 90 minutes later. My review judged it against a read of uploads.ts I had taken before #830 merged and repeated eleven hours after, without re-reading. The mechanism of my error is worth naming since this review is now part of the record: I re-verified the half of the claim I was adding and carried the half I was defending unchecked. Same failure genus as the comments this sprint kept cataloguing, in a review that catalogued two of them.

What this changes for the merge: nothing except it gets simpler. The chain is now #843 → this PR → reprovision. No openclaw follow-up commit is needed. One question survives, now load-bearing where it was previously peripheral: whether the extraction binaries (officecli/pdftotext/markitdown) are actually present in the gateway image at this pin — declaration isn't sufficiency, and with raw bytes restored the extractors are the real read path for binary formats.

@samxu01
samxu01 force-pushed the ci/wire-moltbot-tool-contract branch from 78adda3 to c410304 Compare August 5, 2026 19:34

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. All 10 checks pass, and — more to the point — the guard this PR wires up has now been observed doing real work in the real environment, which is the one thing its unit tests could not establish.

First, what this PR actually is

Worth stating plainly because it's been discussed as CI wiring: #840 is the pin bump. Three files:

.github/workflows/tests.yml   + submodules: recursive, + run: npm run verify:moltbot-tools
.gitmodules                   branch = rebase-2026.3.29  →  main
_external/clawdbot            00821479...  →  70bd82b8...

That's the commit that moves the gateway source onto the pin carrying poppler-utils / markitdown / pypdf. The CI step is the smaller half.

Note the .gitmodules branch change lands in the same commit as the pin bump — and the guard checks ancestry against the declared branch. Both sides of that check move here. They happen to be consistent (70bd82b8 is on main), but it's worth knowing that this one PR is the only place where a mismatch between them couldn't be caught by comparing against the previous state.

The guard works, and I can show which path carried it

From this PR's own run (31040052944, created 19:34:35Z — 83s after #843 merged, so it tested the merge ref against post-#843 main):

git -c protocol.version=2 submodule update --init --force --depth=1 --recursive
Submodule path '_external/clawdbot': checked out '70bd82b8...'
[moltbot-tool-contract] OK — 70bd82b80f is an ancestor of main, the branch .gitmodules declares.

The submodule really is cloned at --depth=1 — the adverse shape, confirmed from the log rather than predicted. And the detail string is is an ancestor, not is the tip, so the fast path did not fire. The hard path carried it.

I reproduced the shape locally to find out which rung:

depth-1 clone of openclaw main (tip 38f717bc), pin fetched at depth 1
  merge-base --is-ancestor 70bd82b8 origin/main   →  1   WRONG      .git 36M
  git fetch --deepen=64 origin main               →  896ms         .git 37M
  merge-base --is-ancestor 70bd82b8 origin/main   →  0   correct

So rung 0 answers wrong on every run and rung 1 rescues it. Without @ux-lead's ladder this PR would be red right now — that's not a hypothetical, it's the exit status above. The "reds at rest" prediction was correct; the ladder is what makes it not happen.

And it still discriminates

A green on a good pin proves nothing about the ability to red. Built an orphan and checked:

commit built on the pin, never pushed
  merge-base --is-ancestor <orphan> origin/main  →  1   correctly rejected

--is-shallow-repository stays true after the deepen, so a real orphan keeps climbing to 256 and --unshallow before anything is declared. The expensive path hangs off the accusing verdict and the happy path stays cheap. That's the right shape.

One correction, non-blocking, doc-only

scripts/verify-moltbot-tool-contract.js carries both this claim and its refutation, ~25 lines apart:

:292 — Terminal regardless of shallowness, and the reason the ladder is cheap — the common case never climbs.

:261 — Measured on the real post-merge state in CI shape (pin one hop back, as a merge commit's second parent): depth-1 → 1 (wrong); after --deepen=64 → 0 (correct)

The second is right and describes the steady state: the pin sits one merge-commit behind openclaw's tip, which is where a pin normally sits. So the common case climbs every time — ~900 ms and ~1 MB per run. The cost is fine; the comment isn't, and it's the kind of premise someone later optimizes against. Suggest: "the common case climbs exactly one rung; only a genuine orphan pays the full ladder."

Not verified

  • Whether pip3 install --break-system-packages markitdown pypdf puts markitdown on PATH in the built image — unresolvable until a rebuild exists; command -v markitdown closes it in one command.
  • Timing attribution in CI specifically: the whole step ran in 850 ms, which is tighter than my local 896 ms deepen alone. Runner-to-GitHub network is faster than mine, so the ladder still fits — but I measured the rung locally and inferred the CI one. If it matters later, a --deepen log line would settle it.
  • This PR merging does not deploy anything. deploy-dev.yml is workflow_dispatch: only — its own comment says the push: branches: [main] flip was left to a follow-up that hasn't landed. Merge → dispatch Deploy Dev from main → confirm git ls-tree <tag> _external/clawdbot is 70bd82b8 → reprovision.

@lilyshen0722
lilyshen0722 merged commit 934df6d into main Aug 5, 2026
12 checks passed
@lilyshen0722
lilyshen0722 deleted the ci/wire-moltbot-tool-contract branch August 5, 2026 19:50
samxu01 pushed a commit that referenced this pull request Aug 5, 2026
Follow-up to this branch's own fix, which was still imprecise. It said the
fast path fires "right after a bump". #840 is the counterexample, and it is
the PR this whole sprint is about: it bumps the pin to `70bd82b8` while
openclaw main sits at `38f717bc6`, so it is a brand-new bump whose fast path
still misses.

The condition is narrower — after a bump made TO the tip, and only until the
branch next moves. Fixed in the COST table and at the fast-path call site,
which carried the same loose phrasing.

Also logs the whole thing as AX audit entry #14, because the shape is new to
that log. Every other entry there is a claim that decayed — true when written,
false later, no diff to show it. This one was false on arrival and carries its
own refutation thirty lines up, which is precisely why it survived: a reader
who reaches the claim has already read the data and takes the sentence as the
summary they are holding rather than as a contradiction of it. Proximity hid
it, not distance. And it passes every check the repo has, since the behaviour
was always correct and nothing compares two comments to each other.

Precise wording from @ux-lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Aug 5, 2026
…kip (#845)

* docs(agents): the ladder's common case climbs one rung, it does not skip

Two comments claimed the fast path is the normal one. It isn't, and the
measurement contradicting it was already in the same function, thirty lines
above the claim:

  // Measured on the real post-merge state in CI shape (pin one hop back...)
  //   depth-1            is-ancestor → 1  (wrong)
  //   after --deepen=64  is-ancestor → 0  (correct)
  ...
  // cheap — the common case never climbs.

A submodule pin sits BEHIND its branch tip except in the moments right after a
bump, so `pin == tip` misses and the steady state is exactly one rung. #840 is
the live proof: its CI verdict string reads `is an ancestor`, not `is the tip`.

Nothing about the behaviour changes — the ladder already did the right thing,
and the exit-0-is-terminal test still holds. What was wrong is the premise
filed alongside it. "Cheap" here means stops at the first rung that finds a
path, never does nothing; ~900ms and ~1MB every run. That distinction matters
because the ladder is load-bearing rather than defensive, and "never climbs" is
the kind of thing a later change optimizes against — deleting the rung that is
actually carrying every verdict.

Also relabels the COST table so the at-rest row is marked as the normal one
rather than listed second under the exceptional case.

Caught by @sprint-review, who reproduced the CI shape and read the exit status
rather than the comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(agents): the fast path is narrower than "right after a bump"

Follow-up to this branch's own fix, which was still imprecise. It said the
fast path fires "right after a bump". #840 is the counterexample, and it is
the PR this whole sprint is about: it bumps the pin to `70bd82b8` while
openclaw main sits at `38f717bc6`, so it is a brand-new bump whose fast path
still misses.

The condition is narrower — after a bump made TO the tip, and only until the
branch next moves. Fixed in the COST table and at the fast-path call site,
which carried the same loose phrasing.

Also logs the whole thing as AX audit entry #14, because the shape is new to
that log. Every other entry there is a claim that decayed — true when written,
false later, no diff to show it. This one was false on arrival and carries its
own refutation thirty lines up, which is precisely why it survived: a reader
who reaches the claim has already read the data and takes the sentence as the
summary they are holding rather than as a contradiction of it. Proximity hid
it, not distance. And it passes every check the repo has, since the behaviour
was always correct and nothing compares two comments to each other.

Precise wording from @ux-lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Aug 5, 2026
)

* fix(ax): the tool-contract guard skipped the strongest cue surface

The guard reads the presets.ts cycles trailer and the inline mention cues.
It did not read backend/services/heartbeatCue.ts — which by ADR-012 s10.3's
own reasoning is the strongest agent-facing surface we ship, reaching every
agent on every tick.

That gap had already been paid for. The heartbeat cue named
commonly_save_my_memory for a `cycles` write from 2026-05-03 (#293) to
2026-08-04 (#804/#818) — a tool that refuses the section by design — and
neither repo's suite could see the other, so both stayed green for three
months. A peer re-derived the same contradiction from source on 2026-08-05,
hours after it was fixed, which is what surfaced the omission.

Adds the cue as a required-tool source. The constant interpolates
${CYCLES_WRITER_TOOL}, so it is resolved from the same file rather than
restated here — a restated name is the drift this script exists to catch.
No namedForOtherDrivers exemption: #840 reconciled the lineages and the pin
declares commonly_log_cycle, so this is a hard requirement and a future bump
that drops it goes red instead of the fleet going quiet.

Two assertions the name-matching check cannot make, because existence is not
capability — the #295 cue would PASS a declares-the-tool check against
today's pin, since commonly_save_my_memory is declared at :526 right above a
description saying `cycles` is intentionally unavailable there:

  - the cue names exactly one writer tool, so a cue that also names a second
    (the #295 shape, and the shape a well-meaning "mention both" edit
    reproduces) fails
  - the escape-hatch clause survives. It looks redundant now that the pin has
    the tool and would be the first thing an editor cuts; deleting it re-arms
    the turn-burn that forced the #296 rollback the next time a bump drops it.

Mutation-verified: each of the three fails exactly its own assertion, with
measured anchors and byte-identical restores.

CLAUDE.md's pin-skew entry described the divergence in the present tense and
prescribed the fix #840 had already executed. It cost a wrong claim in the
sprint pod nine hours later. Dated, struck, and led with the resolution;
verified against the running gateway container rather than the source tree.

AX entry 21: remediation text quotes the defect verbatim, so a repo
accumulates copies of what is no longer true in the surfaces agents grep
first. Six verbatim copies of the dead cue, zero live ones — and the only two
queued events matching it were the peer's own reports about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the cycles silence is moltbot-scoped, and dated by measurement

The pin-skew entry asserted the skew caused "the fleet's" cycles silence
with an undocumented ~87-day figure. Measured against the memory collection
2026-08-05:

  moltbots (agentName=openclaw)  22 docs with entries, newest 2026-05-24T08:49:56Z
  non-moltbot seats              13 docs with entries, newest same evening

The moltbot cutoff falls on the exact day of the d6e63b2 bundled-skills
bump that dropped commonly_log_cycle — so the root cause is corroborated by
a source independent of the submodule log, rather than inferred from it.

Two corrections this forces. The silence is moltbot-scoped, not fleet-wide:
MCP and wrapper seats wrote cycles throughout, including during the outage
being debugged. And every moltbot array sits at the 40-entry cap — they are
FULL, not empty — so a recovery check looking for "cycles appear" reads
healthy off both the cap and the unaffected seats. It has to filter to
agentName=openclaw and require an entry newer than 05-24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to 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.

1 participant