Skip to content

feat(evals): a NanoClaw image, the controller's failure reason, and evaluations on GKE - #980

Merged
chughtapan merged 5 commits into
mainfrom
feat/evals-hardening
Aug 6, 2026
Merged

feat(evals): a NanoClaw image, the controller's failure reason, and evaluations on GKE#980
chughtapan merged 5 commits into
mainfrom
feat/evals-hardening

Conversation

@chughtapan

Copy link
Copy Markdown
Owner

Three gaps between the evaluation matrix and something that can run it.

The NanoClaw application image nothing built

packages/evals requires MOLTZAP_NANOCLAW_IMAGE as a digest-pinned reference
for sixteen of thirty-two cells, and no artifact satisfied it. The container
contract in agents/nanoclaw/runtime.ts named an entrypoint, a bootstrap
config, a state directory, and a gateway port that nothing implemented.

scripts/build-nanoclaw-image.mjs stages the pinned upstream checkout, the
workspace MoltZap channel with its two packed workspace dependencies, the eval
provisioner, and the skill, then prints the digest-pinned identity the
controller producer already prints. The entrypoint materializes a writable
project root — NanoClaw resolves data/, groups/, and its own manifest from
the cwd — seeds the eval agent group, starts NanoClaw, and republishes its
owner-local CLI socket on the bridge port. The relay carries bytes, not a
second protocol: the CLI channel already speaks the NDJSON frames the
controller's gateway decodes.

Built and run end to end: the provisioner applied NanoClaw's seventeen
migrations and initialized eval-agent, NanoClaw reached NanoClaw running,
data/cli.sock appeared, the bridge accepted a connection on 18790, and both
MCP server shapes reached NanoClaw's container config intact.

A NanoClaw cell also needs a reachable container runtime. NanoClaw executes
every agent turn in a container it spawns itself and refuses to start when no
runtime answers. The image carries the client and reads DOCKER_HOST; nothing
in cluster/kubernetes/objects.ts gives a Sandbox a daemon today, so that
remains open and is the one thing between "the image starts" and "sixteen
NanoClaw cells run".

The failure reason that was collected and then discarded

cluster/watch.ts collects up to 200 bounded, credential-redacted lines of
controller output. A decodable failure summary makes that a result rather than
an activity failure, so the detail was dropped where it was collected, the
submitter's stdout line had no field for it, and the evaluation CLI wrote a
canned sentence into SQLite. The run's namespace is deleted on the way out, so
the Pod log was gone too — an operator saw only "the simulator controller
reported an infrastructure failure".

The reason now rides the two attempts that exist to record one.
RunFailedAttempt.detail and LedgerAllocationFailedAttempt.failure.detail
held a constant; they hold the controller's own account when there is one, and
phoenix-run.ts already published exactly those two fields as a run's error, so
it reaches Phoenix with no new plumbing. No new error class, no new attempt
kind, no migration.

Three shapes collapse on the way. ControllerObservation had one failed
member with an optional result and a detail that was dead whenever the result
was present — two states wearing one tag, free to disagree; it is now
completed and failed. The composed failure detail is sanitized once over the
whole string rather than per part, because bounding the pieces separately let
their concatenation exceed the bound and the trim then ate the Job's condition
reason. The submitter's bound trims the byte array and steps off continuation
bytes, since a UTF-16 slice cannot express a byte count.

isEntryModule — which both simulator profile entrypoints already use — guards
the evaluation CLI, so cli.ts can be imported and its attempt constructors are
testable at all for the first time.

Evaluations on GKE, and a README that was wrong twice

Running a sweep against GKE was an undocumented sequence: publish the controller
image, resolve its pushed digest, hold a port-forward that survives being
dropped, export six variables — five of which are properties of the cluster the
operator had just attached to. cluster.sh evals performs it and hands the rest
of its arguments to the sweep; cluster.sh publish-image exposes the publish
alone. The NanoClaw image stays a passthrough: it is the runtime under
evaluation, not this cluster's infrastructure.

Not exec: the forward supervisor is a background job of that shell, and
replacing the shell would strand it with no trap left to reap it.

The README claimed the profile has no Temporal deployment while setup applies
one, and claimed live GKE qualification could not be proven after a
hundred-agent run had completed. The replacement says what the decision log
actually supports — the run happened, its ledger is retained nowhere a reader
can check — and leaves the gate open on the evaluation half rather than all of
it. MOLTZAP_TEMPORAL_CLUSTER_ADDRESS was documented nowhere and is the one
endpoint no other file explains: a worker Pod cannot reach a localhost forward,
and a worker that never connects looks exactly like an empty queue.

Verification

build, lint (incl. knip), format:check, typecheck, arch:check,
docs:check:drift, docs:check, docs:check:mermaid, docs:check:gates-test,
test:compute-next-version, simulator-packages.mjs, all four profile/image
checks, and nx affected -t typecheck:tests test test:integration (simulator
45 files/280 tests, evals 13/102).

🤖 Generated with Claude Code

chughtapan and others added 5 commits August 6, 2026 01:31
… require

`@moltzap/evals` requires MOLTZAP_NANOCLAW_IMAGE as a digest-pinned reference
for half the matrix, and nothing produced one: the NanoClaw container contract
in `agents/nanoclaw/runtime.ts` named an entrypoint, a bootstrap config, a state
directory, and a gateway port that no artifact implemented.

`scripts/build-nanoclaw-image.mjs` stages the pinned upstream checkout, the
workspace MoltZap channel and its two packed workspace dependencies, the eval
provisioner, and the skill, then prints the same digest-pinned identity the
controller producer prints. The entrypoint materializes a writable project root
— NanoClaw resolves `data/`, `groups/`, and its own manifest from the cwd —
seeds the eval agent group, starts NanoClaw, and republishes its owner-local CLI
socket on the fixed bridge port. The relay carries bytes, not a second protocol:
the CLI channel already speaks the NDJSON frames the controller's gateway
decodes.

A built image was run end to end: the provisioner applied NanoClaw's seventeen
migrations and initialized `eval-agent`, NanoClaw reached `NanoClaw running`,
`data/cli.sock` appeared, and the bridge accepted a connection on 18790.

NanoClaw runs every agent turn in a container it spawns itself and refuses to
start when no runtime answers, so a cell also needs a reachable container
runtime; the image carries the client and the platform supplies the endpoint.

Both image failures in the evaluation CLI now name the script that produces the
reference, because neither is a value an operator already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Failure detail was sanitized three times and then discarded. `cluster/watch.ts`
collects up to 200 bounded, credential-redacted lines of controller output; a
decodable failure summary makes that a result rather than an activity failure,
so the detail was dropped where it was collected, the submitter's final JSON
line had no field for it, and the evaluation CLI wrote a canned sentence into
SQLite. The run's namespace is deleted on the way out, so nobody could go read
the Job's log afterwards either — an operator saw only "the simulator controller
reported an infrastructure failure".

The reason now rides the two attempts that already exist to record one.
`RunFailedAttempt.detail` and `LedgerAllocationFailedAttempt.failure.detail`
carried a constant sentence; they carry the controller's own account when there
is one, and `phoenix-run.ts` was already publishing exactly those two fields as
a run's error, so the reason reaches Phoenix without a line of new plumbing.

Three shapes collapse on the way:

`ControllerObservation` had one `failed` member with an optional result and a
detail that was dead whenever the result was present — two states wearing one
tag, free to disagree. It is now `completed` (a decodable result, successful or
not) and `failed` (no result, and this is the only account of why).

The composed failure detail is sanitized once over the whole string rather than
per part. Bounding the pieces separately let their concatenation exceed the
bound, and the trim then ate the front — which is where the Job's condition
reason is.

The submitter's bound trims the byte array directly and steps off continuation
bytes. A UTF-16 slice cannot express a byte count, and the previous convergence
loop re-encoded the string up to fourteen times to work around that.

`isEntryModule` — which both simulator profile entrypoints already use — now
guards the evaluation CLI, so `cli.ts` can be imported. The image-producer
vocabulary moves back beside the environment it describes, and the attempt
constructors are testable at all for the first time.

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

Running an evaluation sweep against GKE was an undocumented sequence an
operator had to reconstruct: publish the controller image, resolve its pushed
digest, open a port-forward that survives being dropped, and export six
variables — five of which are properties of the cluster they had just attached
to. Getting one wrong measures a different cluster than the one they think.

`cluster.sh evals` performs that sequence and hands the rest of its arguments to
the sweep, reusing the image publish and the forward supervisor `run` already
owns. `cluster.sh publish-image` exposes the publish alone, printing only the
digest so a caller can assign it. The NanoClaw image stays a passthrough: it is
the runtime under evaluation, not this cluster's infrastructure.

Not `exec`: the forward supervisor is a background job of this shell, and
replacing the shell would strand it with no trap left to reap it.

Two README defects go with it. It claimed the profile has no Temporal
deployment while `setup` applies one, and it claimed live GKE qualification
could not be proven after a hundred-agent run had completed. The replacement
says what the decision log actually supports — the run happened, its ledger is
retained nowhere a reader can check — and leaves the gate open on the
evaluation half rather than on all of it.

`MOLTZAP_TEMPORAL_CLUSTER_ADDRESS` was documented nowhere, and it is the one
endpoint no other file explains: the worker Pod cannot reach a localhost
forward, and a worker that never connects looks exactly like an empty queue.

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

An MCP server became either a stdio spawn or a streamable-HTTP endpoint while
this series was in flight, and the container runtime now passes the definition
through untouched. The image entrypoint still rebuilt the stdio shape by hand,
so a `{name, url}` server reached NanoClaw as a command that does not exist and
a URL that was never written down.

It is not this file's definition to rebuild. Rekeying by name and forwarding the
rest verbatim also means the pinned NanoClaw revision — which understands the
stdio shape only — starts honouring a URL server on a revision bump, with no
second change here.

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

`run` and `evals` each carried their own copy of the prelude that reaches the
cluster — attach, publish, forward, trap. The trap is the part that reaps a
leaked port-forward, and a forward that leaks still accepts connections while
proxying to a pod that no longer exists, so two copies meant a fix could reach
one verb and miss the other. One `begin_cluster_session` owns it.

The image build had two spellings of one rule: the repository argument was
checked for `@` when it arrived and for `@` or whitespace when the reference was
built, so a whitespace repository passed the first and failed the last statement
after a 45-minute build. `assertRepository` is now the only rule, and the test
that grepped the old spelling out of the source text calls it instead.

The entrypoint materializes its project root concurrently — every entry targets
a distinct path, and this runs before the bridge port opens, which is what the
controller reads as readiness. `data` leaves the mutable list, because the copied
install already carries it with its stamped marker. The provisioner uses spawn's
own timeout rather than a hand-rolled timer, and `startNanoClaw` stops returning
a child handle no caller signals.

The final image drops ~32MB no agent turn reads: upstream's marketing imagery,
its translated docs, and the SQLite C amalgamation better-sqlite3 keeps beside
the prebuild it loads. The agent pool autoscales from zero, so that was pulled
onto a fresh node for every run.

Verified by rebuilding and running the image: gateway listening on 18790,
upgrade marker stamped, workspace skill seeded, and both MCP server shapes
intact in NanoClaw's container config.

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

mintlify Bot commented Aug 6, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
moltzap 🟢 Ready View Preview Aug 6, 2026, 9:45 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chughtapan
chughtapan merged commit 1e0f3e5 into main Aug 6, 2026
4 checks passed
@chughtapan
chughtapan deleted the feat/evals-hardening branch August 6, 2026 09:48
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