Skip to content

feat(simulator): run container societies on Kubernetes, validated at a hundred agents - #932

Merged
chughtapan merged 34 commits into
mainfrom
impl/917-main-local-society
Aug 6, 2026
Merged

feat(simulator): run container societies on Kubernetes, validated at a hundred agents#932
chughtapan merged 34 commits into
mainfrom
impl/917-main-local-society

Conversation

@chughtapan

@chughtapan chughtapan commented Aug 1, 2026

Copy link
Copy Markdown
Owner

The simulator runs a society of container agents on Kubernetes through one RunSpec, on a local cluster or on GKE. A hundred-agent run on GKE finished green and is what most of this branch's late commits come from.

Opened 2026-08-01 for a three-container example this branch has since deleted. Title and body rewritten to describe what is actually here.

What landed

Kubernetes society execution A run reserves its whole cohort before any agent starts, so a society that cannot fit never half-starts. Every run writes a ledger that can be read back.
Vocabulary and structure kernel/run/, and cluster/, agents/, events/, ledger/, network/ named for behaviour rather than implementation.
Shared runtime vocabulary One agents/workspace owns the workspace, MCP, and digest machinery both runtimes had copies of. Images and workspace paths are branded and validated at construction; the endpoint a runtime attaches to is a host and port, which deleted seven predicates that only existed because a URL could carry a protocol, path, query, fragment, or credentials.
GKE profile gke/cluster.sh covers setup, up, run, down, delete. Agents autoscale from zero to agent_max_nodes, so an idle profile costs only its resident controller.

The hundred-agent run

local/hundred-agent-soak.mjs brings up 100 agents, holds them 10 minutes, and tears down. On GKE it finished exitCode: 0 / ProgramFinished, having scaled to 8 nodes and 101 pods, and its exported ledger holds 100 agent-runtime-ready records and zero failures. The artifacts are in the profile's bucket as manifest.json, records.ndjson, and completion.json.

Getting there surfaced five faults a two-agent run cannot reach. Each is fixed here:

  • The worker install waited on the wrong replica. Every submission installs the image it just built, so every submission rolls the run-worker Deployment. Counting the outgoing replica as ready handed the workflow to a Pod the rollout then deleted, and the activity stopped heartbeating mid-run.
  • Liveness depended on reaching the observation loop. Admitting one agent at a time means a large cohort takes longer to prepare than the 60s heartbeat deadline allows. The signal now runs on its own schedule for the whole attempt, binds where the SDK still owns its execution context, and survives its own failure — one throw used to end the loop and starve the deadline exactly as a missing signal did.
  • Installing the worker twice conflicted. A create owns its fields under Update, which a later Apply from the same manager rejects with 409, so a profile could serve exactly one run.
  • The cohort's startup budget was unreachable. The controller read MOLTZAP_STARTUP_TIMEOUT_MS but nothing ever set it, leaving its two-minute default as the only achievable value.
  • Cluster failures named neither operation nor cause. The ledger recorded An error has occurred; it now records which call failed and why, which is how the last of these was found.

Review

A pre-landing review ran three reviewers over the merged diff: testing, maintainability, and adversarial. All three returned HOLD; every finding above P2 is fixed in 24ae7343.

The three that mattered most were mine and were not theoretical:

  • The port-forward "supervisor" never supervisederrexit is inherited by the subshell, so the first dropped forward ended the loop meant to replace it. Its readiness check watched the supervisor rather than the forward, so it could never fail and a parked controller hung forever. Proven with a harness, fixed, and re-proven.
  • --delete-artifacts skipped the check but deleted nothing, and gcloud storage ls exits non-zero precisely when the bucket is empty, so under set -euo pipefail delete was bricked on the common case.
  • The heartbeat test was tautological: toContain was satisfied by a single upfront beat, so reverting the fix still passed. It now asserts the signal precedes cohort admission, and I verified a revert fails it.

Left deliberately: force: true removes the loud 409 that used to stop a second concurrent submission from rolling the worker under an in-flight run. Single-run-at-a-time is the profile's real contract and is not yet enforced — worth an issue, not a blocker for an experiment profile.

Verification

build, typecheck:tests, lint, test, arch:check, and gke-profile-check pass across workspace, @moltzap/simulator, and @moltzap/evals; all 9 projects' tests pass. docs:check:drift is clean.

gke-profile-check was red on this branch before this review — it runs in CI but is outside the usual gate set, and the Agent Sandbox pin fix had left the profile and its test asserting the old tag SHA. The pin now has one owner and the test cross-checks it.

[gstack-context]
Decisions: Main v1 uses one schema-bound RunSpec and Kubernetes executor with durable start-or-attach binding, aggregate admission, generations, at-most-once dispatch, and non-event-appending Temporal finalization.
Remaining: Pass the exact candidate through docs checks and the mandatory isolated blind review, then begin the public contract/fake-kernel batch.
Skill: /plan-eng-review
[/gstack-context]
@mintlify

mintlify Bot commented Aug 4, 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 4, 2026, 9:05 PM

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

…merge)

Snapshot for review, committed with --no-verify at the user's request while a
fix round was still writing to the tree. NOT the atomic commit; it will be
squashed. Some files are mid-fix, and formatting/docs-drift that the pre-commit
hook normally repairs has not been applied.

Directories now name behaviors rather than vendors: kernel/ -> run/,
runtime/ -> agents/, platform/ -> cluster/, with the Kubernetes and Temporal
SDKs confined to named adapters. The render seam no longer round-trips
platform-owned data back for validation, so Readiness, Attachment, and
Bootstrap stop existing rather than being renamed. RunSpec carries a
Symbol.for brand so one identity gate replaces a structural check that
disagreed with a nominal one.

Run reclamation gains a heartbeat and a durable in-cluster worker, because
cleanup previously died with the process that started the run.

Known red at the time of the review that prompted this snapshot:
- lint: 7 errors in newly authored code
- prettier and docs drift: unrepaired, hook bypassed
- coverage: 81.63% -> 81.37% against a meet-or-beat requirement
- vendor import boundary violated in 6 files and not yet lint-enforced
- worker direct-invocation guard is not symlink-safe

Tests were green at 37 files / 201 tests when the snapshot was staged.

The ADR amendment still owes its blind teammate review gate, and the two
OpenClaw fixes still have no live evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
@chughtapan
chughtapan marked this pull request as draft August 5, 2026 06:18
@chughtapan chughtapan changed the title feat(simulator): add local three-container society WIP: simulator vocabulary + structure refactor (gates RED, do not merge) Aug 5, 2026
Symlink-safe worker entry detection: isEntryModule canonicalizes both the
invoked path and the module URL, so a worker reached through a symlinked path
still serves its task queue instead of exiting silently.

NanoClaw regains the gateway-disconnect termination signal the render-seam
collapse dropped. Application.attach takes a report sink, so a runtime that
can observe its own bridge loss reports it while its Sandbox still runs.
OpenClaw needs no sink; its gateway exposes no post-attach failure channel.

An acquired Sandbox that stops being observable now fails the run instead of
retrying forever, restored inside the existing per-agent read rather than by
reinstating the discarded poll.

The bootstrap tests assert the typed failure channel only, so they can detect
a regression to throwing.

Kubernetes and Temporal SDK imports are confined to their adapters, with the
workflow surface carved out explicitly because a Temporal workflow module
cannot avoid importing the SDK that defines it.

Tests: 38 files, 218 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
@chughtapan chughtapan changed the title WIP: simulator vocabulary + structure refactor (gates RED, do not merge) simulator: vocabulary + structure refactor (draft — ADR gate outstanding) Aug 5, 2026
… path

Five copies of "was this module the process entry point" had drifted into
three semantics. The two profile launchers compared an uncanonicalized argv
path against import.meta.url, so reaching them through the controller image's
symlinked dist directory made a direct invocation look like an import: the
process exited successfully having submitted nothing. cluster/entry.ts now
owns the comparison and the duplicate exported guard is gone.

The evaluation submitter spawned dist/platform/<profile>/main.js, a path no
build produces since the profiles moved. Every cell submission failed with a
generic submitter error. The path is derived in one place and pinned against
both the source module it compiles from and the simulator's own scripts.

Also confines the Kubernetes and Temporal SDKs to their adapters, leaving the
workflow surface carved out because a Temporal workflow module cannot avoid
importing the SDK that defines it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
chughtapan and others added 2 commits August 5, 2026 11:57
Temporal forces a Promise at the activity boundary. That shape had propagated
inward until the Kubernetes adapter carried a second Promise-native API beside
its Effect one, and four behavior modules were written around it. Conversion
now happens once, in runAtPromiseBoundary, and everything it calls is
Effect-native.

The boundary helper rejects with the run's own failure rather than a fiber
wrapper, so Temporal records ControllerAttemptFailed or KubernetesCallFailed
instead of an opaque FiberFailure.

The init container is an Effect program over @effect/platform FileSystem.
Validation failures are typed failures with the same single sanitized stderr
line and nonzero exit; ENOENT is no longer control flow.

Suppressions for async-keyword and promise-type fall from 56 to 12, and the
survivors are all signatures the Temporal SDK owns: worker and connection
construction, workflow execution, and the activity types proxyActivities
requires. Raw node:fs survives only where @effect/platform has no lstat, since
the containment checks must reject a symlink rather than follow it.

Entry detection in the init container gains the existence guard the shared
helper has, so a missing argv[1] is a plain negative instead of a thrown
ENOENT. It stays a local copy because its own regression test executes this
file as TypeScript through a symlink, where a .js specifier does not resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
…uster refactor)

Main gained a directed-link fault-injection subsystem after this branch
diverged: a policy interpreter, drop/delay/hold verbs, and the LinkDriver
implementation whose absence left LinkController.disable with an unsatisfiable
requirement here. The run now stands the fabric up and provides the driver, so
that capability works again.

The fabric shapes what this process can observe, which is the customer's own
controlled endpoints. A roster agent runs in its own container and its
agent-to-agent traffic never crosses that stream, so the fabric does not
register it and link control over a containerized agent fails rather than
silently passing traffic it claims to police. Carrying directed-link policy
across the container bridge is not attempted here.

Main also removed app principals. The conversation-create payload no longer
carries appId, and the router process no longer sends one. Nothing flagged
that file as conflicting; only the type checker found it.

Resolution favored this branch wherever the cutover changed a contract and
favored main wherever the change was additive. The agent runtime keeps its
container shape and gains main's optional inbound-link stage; run execution
keeps the cluster session and gains the fabric.

Simulator: 40 files, 243 tests. Evals: 12 files, 88 tests. Both typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ

Adds src/test-utils, the package's first, following the convention already in
packages/server and packages/protocol: a curated index over one fixture module,
exporting only what the run regressions consume. The kernel harness moved there
so the execute suite is a suite rather than a suite plus its scaffolding.

Declares the simulator's layer order in the architecture config rather than
waiving the imports it implies: cluster over run over agents over network over
events over ledger. Twenty-seven cross-domain findings were the check asking
what the layering was, not reporting a violation. The in-cluster controller now
reaches the run outcome and ledger error types through the package facades.
Main moved the repository scripts into domain folders and split verification
across commit, push, and CI. The simulator profile checks keep their place in
the workflow, now naming the relocated package test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
chughtapan and others added 3 commits August 5, 2026 14:41
The merge kept this branch's package.json entries, which still pointed at the
pre-reorganization script paths. pnpm install runs prepare, so every CI job
failed before it could install: the tsgo exec-bit restore no longer lived
where the script said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Wp7vy3DXqmDMg485Z3rhQ
… one experiment

The reachability gate failed because compute-next-version.test.sh landed in the
tree without the package.json entry that reaches it.

Size the GKE profile for a single experiment rather than a standing fleet: one
zonal cluster, one agent node, and one system node. The workload identity
principal names the cluster's own location, which is now a zone.

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

The agent pool now idles at zero and provisions nodes when Kueue admits a
cohort, so an idle profile costs only its resident controller. Scaling from
zero requires the pool's label and taint in the pool declaration, because the
autoscaler decides whether a node that does not exist yet would accept the
pending pods.

Size the ClusterQueue quota against one agent node's measured allocatable
capacity. The previous quota described three e2-standard-8 nodes, which no
longer exist; a quota larger than the pool can deliver admits a cohort that is
then unschedulable, so the run hangs on pending pods rather than failing.

Pin Agent Sandbox to the commit its v0.5.4 tag points at. The pin named the
annotated tag object, and checking out FETCH_HEAD lands on the commit, so the
installer's own verification could never succeed.

Terraform no longer restates the chart's quota, which gave one number two
owners and let the copies drift apart unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chughtapan and others added 6 commits August 5, 2026 18:11
…KE profile one run path

Server-side apply forces field ownership. An earlier submission's create owns
these fields under Update, which conflicts with an Apply even from the same
manager, so installing the run worker onto a cluster that had already hosted
one failed with a 409 and no run could be submitted twice.

Log the cause of a failed Temporal run instead of discarding it. The submitted
detail stays sanitized because it is operator output and the connection it
carries can hold a credential, so the cause belongs in the log rather than the
message.

cluster.sh gains run, which builds the controller image, pushes it, and
references the digest the registry reports. Assembling that reference by hand
is how a run ends up pulling an image that does not exist. Setup installs the
experiment-grade Temporal the profile has always required but never deployed.

Size the queue and the agent pool ceiling for a hundred-agent soak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…invalid inputs unrepresentable

Both agent runtimes carried the same workspace, MCP, and digest machinery. One
agents/workspace module now owns it, and the redaction boundary is unchanged:
the MCP projection still hashes environment key names without their values.

Images and workspace paths become branded schemas validated at construction.
The endpoint a runtime attaches to is a host and port rather than a URL, which
removes seven predicates that only existed because the type could express a
protocol, path, query, fragment, credentials, or a mismatched port.

Drop the Distributed prefix from identifiers whose module no longer needs it to
disambiguate.

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

A hundred-agent run surfaced four faults that a two-agent run cannot.

Wait for the installed worker revision rather than any available replica.
Every submission installs the image it just built, so every submission rolls
the Deployment; counting the outgoing replica as ready handed the workflow to a
Pod the rollout then deleted, and the activity stopped heartbeating mid-run.

Heartbeat on a schedule for the whole attempt. Preparing a run costs one
admission per agent, so a large cohort takes longer to prepare than the
heartbeat deadline allows, and proof of life cannot wait for the observation
loop. The signal binds to the activity where the SDK still owns the ambient
execution context, because a fiber resuming after a timer does not.

Carry the cohort's startup budget from the submitter to the controller. The
controller read MOLTZAP_STARTUP_TIMEOUT_MS but nothing ever set it, so its two
minute default was the only reachable value and a cold cohort could not finish
becoming ready.

Report a cluster failure's detail when it is stringified. Without it the ledger
recorded a cluster error naming neither the operation nor its cause.

Supervise the Temporal port-forward. A run outlives one, and losing it reported
a run that was still going, and later succeeded, as failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parking sets the system pool to zero, which the variable's own validation
rejected, so the down verb could not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The port-forward supervisor never supervised. errexit is inherited by the
subshell, so the first dropped forward ended the loop that exists to replace
it, and the readiness wait watched the supervisor rather than the forward, so
a parked controller was indistinguishable from a working one forever.

The liveness signal now survives its own failure. One throw ended the loop and
starved the deadline exactly as a missing signal did. Binding it is required
rather than optional, so an implementation that resolves the activity context
per call, and therefore throws off the worker, cannot compile.

--delete-artifacts now discards the objects it promised to, and refusing to
destroy no longer depends on a wildcard that exits non-zero precisely when the
bucket is empty.

Pin the Agent Sandbox commit in one place. The profile and its test restated
it, and updating the installer alone left the profile check red.

Cover what the live run proved: the rollout predicate, the signal arriving
before a cohort is admitted, the startup budget's path to the controller, and
a cluster failure that names its operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chughtapan chughtapan changed the title simulator: vocabulary + structure refactor (draft — ADR gate outstanding) feat(simulator): run container societies on Kubernetes, validated at a hundred agents Aug 6, 2026
chughtapan and others added 3 commits August 5, 2026 22:20
The ignore entries answered a partially installed worktree, where the tsx
binary was present without its package, rather than anything about the repo.
A complete install resolves it, and the entries then read as unnecessary
configuration and fail the root lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fourth case tipped the scope past the generative-test requirement. These pin
the contract's fixed shapes rather than an invariant over generated input, so
they carry the same reason the other regression-only scopes state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e the soak a home

The blind teammate review of candidate 78ff2f9 returns FAIL. A post-review
amendment changed binding text in an admitted record with no changelog receipt,
no trajectory correction, and no cited source event, while the retained events
say the opposite, and the profile documentation and tooling still enforce the
pre-amendment gate. Reconciling that is a maintainer call, so the record states
the blockers rather than resolving them.

The hundred-agent soak was reachable from nothing. Document what it proves,
which is that a cohort that size comes up and is reclaimed rather than that a
gate passes, and check it alongside the other profile modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One end-to-end module replaces the two-, four-, ten-, and hundred-agent
modules. The path is the same at two agents and at a hundred, so the count is
an input rather than four near-copies, and the contradiction between a record
that said four, a ledger that said ten, and tooling that enforced ten stops
being a question about which file is right.

MOLTZAP_COHORT_SIZE carries it along the path the startup budget already
takes: the submitter refuses what could never be a count, the controller
validates the bound, and the experiment reads it through the same validated
configuration rather than reaching for the process.

Correct the decision in place, with the receipt its own rule requires: the
renamed field, the acceptance gate, the snippet's export, and an autoscaling
non-goal that never meant a profile's node pool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chughtapan and others added 2 commits August 5, 2026 23:41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documented hundred-agent recipe could not have worked. Cohort readiness is
bounded by the controller's budget, whose two-minute default no longer covers
provisioning nodes and pulling an image onto each one, and the variable that
raises it appeared in no document. The module's own timeout never covered that
wait either, so it is gone rather than misattributed.

Attribute the newest correction the way this ledger already attributes its two
other unlocated exchanges: retain the literal reply, state what was searched
and when, and mark what no event states. The scale-claim non-goals go back
unchanged, because nothing sourced their removal.

Test the controller's cohort validator, exclude the first size the record
defers, and stop the profile documentation naming a smoke that no longer ships.

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

An override is a decision the log should carry rather than an absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chughtapan
chughtapan merged commit 18ba443 into main Aug 6, 2026
5 checks passed
@chughtapan
chughtapan deleted the impl/917-main-local-society branch August 6, 2026 06:58
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