Skip to content

feat: boot standalone when the Intelligence contract is absent - #2

Merged
IshmaelRogers merged 3 commits into
mainfrom
feat/standalone-boot
Sep 2, 2026
Merged

feat: boot standalone when the Intelligence contract is absent#2
IshmaelRogers merged 3 commits into
mainfrom
feat/standalone-boot

Conversation

@IshmaelRogers

Copy link
Copy Markdown

Second slice of the bit-mind CopilotKit#20 enclave work, stacked on #1. The server now boots without the Intelligence contract — the M0 milestone bit-mind's service specification asks of this fork ("boots with Intelligence deleted, chat routes removed, admin working; threads/chat are 404 by design"), done additively so the fork stays rebasable on upstream.

What changes

  • RuntimeCapabilities becomes the union its own comment predicted. All four INTELLIGENCE_* / COPILOTKIT_LICENSE_TOKEN values absent now means { mode: "standalone", durableHistory: false } — a working deployment with the admin, people, computer and plugin surfaces and no chat runtime — instead of a refusal to boot. A partial set is still refused, naming what is missing and both valid shapes. This deliberately reverses the old "refuses to start when Intelligence is absent entirely, rather than degrading" test; the replacement test says why, and nothing degrades silently — the mode is stated on /api/capabilities and the absent surfaces are unmounted rather than mounted-and-refusing.
  • Unmounted, not refusing: the chat runtime, /api/threads, and the routine runner all stay off in standalone (createApp already treated each as optional — this is wiring, not route surgery). The handoff delivery loop logs that it stays off if a deployment configures it on without a runtime. mountCopilotRuntime now guards and narrows, so mounting it without the contract fails in front of the deployer — the exact guard the old single-mode comment in index.ts promised.
  • The Intelligence wiring moved behind dynamic imports inside the intelligence-mode branch: the agent loader, buildAgentFor, the headless-turn pair, and the runtime mount, verbatim. This is load-bearing rather than tidy — see the find below.

A pre-existing boot crash, found and fixed in passing

While verifying, bun src/index.ts on the unmodified base branch failed to boot at import time in every mode, on both the pinned Bun 1.3.14 and 1.4.0:

TypeError: require() async module ".../eventsource/dist/index.js" is unsupported. use "await import()" instead.
    at @modelcontextprotocol/sdk/dist/cjs/client/sse.js
    at @ag-ui/mcp-apps-middleware/dist/index.js

The runtime's import graph reaches the MCP SDK's CommonJS SSE client, which require()s the ESM-only eventsource package; whether Bun tolerates that turns out to depend on module-graph shape (each edge imports cleanly in isolation — the full index.ts graph does not). Moving the cluster to dynamic import() sidesteps it: standalone never evaluates the graph at all, and intelligence mode loads it through the path Bun handles. Both modes now boot where neither did. CI never caught this because it never boots the server — worth a follow-up smoke job once Actions are available.

Verified how

Workspace gate: format=ok lint=ok typecheck=ok test=ok1663 pass / 0 fail (five new standalone tests; the two config tests that pinned the old single-mode behaviour updated with the reasoning in-line).

Live boot, standalone (pgvector Postgres, no Intelligence vars, single-user):

/health              -> {"status":"ok"}
/api/capabilities    -> {"mode":"standalone","durableHistory":false,...}
/api/copilotkit/info -> 404   (by design)
/api/threads/t1/...  -> 404   (by design)
/api/me              -> the single-user admin identity
/api/agents          -> 200

Live boot, intelligence mode (all four values set): boots and reports {"mode":"intelligence","durableHistory":true,...} — previously an import-time crash on this box.

Not in this slice

  • The BitMind gateway (feat: add the BitMind gateway — service auth, attestation, and a governed relay #1) still runs as its own process; mounting it into the standalone server is the natural next step now that the server can boot in the enclave's shape.
  • Intelligence-mode behaviour beyond boot is structurally unchanged (same modules, same arguments, loaded at the branch instead of the top) and covered by the existing suite; no live Intelligence endpoint exists here to drive a real chat turn against.
  • Tool descriptors and interrupt emission for BitMind's approval gate remain the next fork slices after the gateway mounts.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reviewed the stacked standalone-mode change against production wiring and deployment surfaces. The focused standalone/config tests, server typecheck, formatting, and lint pass. The inline findings identify cases where standalone is selected implicitly, still evaluates runtime code, advertises or exposes routines/handoffs that cannot execute, and cannot be rendered by the shipped Helm chart.

Comment thread server/src/config.ts Outdated
.filter(([, value]) => !value)
.map(([name]) => name);

if (missing.length === 4) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Please make standalone an explicit deployment choice rather than inferring it from complete absence. If a Kubernetes Secret or environment injection fails, all four values disappear together; this change converts the previous fail-fast crash into a healthy /health response with chat/thread/routine behavior missing. /api/capabilities exposes the mode, but existing liveness/readiness probes need not inspect it. This config already requires an explicit OPENBOT_SINGLE_USER choice when all identity-provider settings are absent; the same pattern (for example OPENBOT_RUNTIME_MODE=standalone) would let the enclave opt in without making a production secret outage look intentional.

Comment thread server/src/index.ts
undefined;

const copilotRuntime = intelligence?.copilotRuntime;
const routineRunner = intelligence?.routineRunner;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This removes only the internal routine runner. createApp still receives routineStore unconditionally below, so /api/routines remains mounted in standalone and can re-enable an existing schedule. The worker reads that same database and will continue offering due runs to /internal/routines/run, which is now a 404, causing repeated dispatch failures. That also contradicts the new capability comment/tests saying routines are unmounted. Please gate the routine management surface/worker behavior for standalone (at minimum prevent enabling), and add coverage for /api/routines plus a due worker dispatch in this mode.

Comment thread server/src/config.ts
.map(([name]) => name);

if (missing.length === 4) {
return { mode: "standalone", durableHistory: false };

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The server can choose this mode, but the shipped Helm chart still cannot render it: charts/openbot/templates/validation.yaml fails when the Intelligence URLs are absent, templates/secret.yaml requires both Intelligence secrets, and the values file documents them as mandatory. Please add a chart runtime mode and condition those validation/secret/env paths, with a Helm render test for standalone; otherwise this new deployment shape is available only through ad-hoc/manual startup.

Comment thread server/src/index.ts
{ createRuntimeAgentLoader },
{ createTurnRunner },
] = await Promise.all([
import("@copilotkit/runtime/v2"),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This boundary does not actually keep the CopilotKit runtime out of standalone evaluation. index.ts statically imports createApp; app.ts statically imports createIntelligenceClient; and intelligence-client.ts statically imports CopilotKitIntelligence from @copilotkit/runtime/v2. The focused test also imports copilot.ts directly, so it cannot prove the stated invariant. Even if the reduced graph happens to boot today, standalone remains coupled to the dependency whose module graph this change says it must avoid. Please inject/build the thread reader in the intelligence branch (or dynamically import intelligence-client.ts) and add a subprocess boot test of src/index.ts.

Comment thread server/src/index.ts Outdated
if (
config.handoff.maxDepth > 0 &&
config.handoff.maxPerRun > 0 &&
!intelligence

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A warning does not make the exposed capability consistent with runtime behavior. Handoff caps default on, and createApp still derives handoff.enabled solely from those caps and permits the related grant surface, while this branch starts no delivery loop. Standalone clients/admins are therefore told a capability is enabled even though every offered hop is undeliverable. Please derive the effective handoff capability from runtime mode plus caps and use that same value for API responses, grant mutations/tool exposure, and listener startup.

@IshmaelRogers

Copy link
Copy Markdown
Author

All five findings addressed in bf57efb (rebased on #1's fix commit).

  1. Explicit opt-inOPENBOT_RUNTIME_MODE=standalone, exactly the OPENBOT_SINGLE_USER shape you pointed at. Total absence of the four values without the mode is again a refusal to boot, so a failed Secret mount can never look intentional; the mode alongside set values is refused as a contradiction; an unknown mode value is refused too. The config tests now pin all four shapes — including the restored fail-fast the earlier commit had deliberately (and, you're right, wrongly) reversed.
  2. RoutinescreateApp gets no routineStore in standalone, so /api/routines is unmounted and nothing can enable a schedule for the worker to dispatch into a 404; useRoutineTools is gated the same way. Tested (GET /api/routines → 404 in the standalone app). The chart refuses routines.enabled with the mode outright (below).
  3. Helmconfig.runtimeMode conditions the chart: validation refuses an unknown mode, the standalone/Intelligence contradiction, and standalone+routines; the Secret omits intelligence-api-key/license-token in standalone; the env helper injects OPENBOT_RUNTIME_MODE=standalone instead of the four INTELLIGENCE_* entries. A ci/standalone-values.yaml target renders end to end (verified locally: OPENBOT_RUNTIME_MODE present, zero Intelligence references in the manifests, both refusals refusing, and self-hosted still rendering), with tests/helm-standalone.test.ts as executable coverage. The one thing I cannot ship: adding standalone to the chart job's workflow matrix — this credential lacks the workflow scope, so that one-line ci.yml change is left to a maintainer (noted in the values file header).
  4. The invariant now actually holdsapp.ts no longer imports intelligence-client: the thread reader is built inside index.ts's intelligence branch, behind the same dynamic imports, and injected as createApp's trailing optional parameter (thread routes mount only when a reader exists). The standalone suite's runtime-mount test imports copilot.ts dynamically so the suite itself no longer touches the graph, and a new subprocess boot test spawns the real bun src/index.ts (cwd server/ so the repo .env cannot leak in), polls /health, and asserts mode: standalone plus the chat 404 — the module graph a deployment loads, not a route table.
  5. Handoff coherence — the caps are zeroed in loadConfig for standalone (with a warning only when the env explicitly set them), so the capability endpoint, the grant surface, and the delivery loop all derive the same disabled answer from the same value; the index-level warning block that only told one of those three is gone. Tested: BOT_HANDOFF_* set + standalone → {maxDepth: 0, maxPerRun: 0}.

Gate: format=ok lint=ok typecheck=ok, server suite 1681/0 (including the subprocess boot test, ~5s, against pgvector Postgres), agent suite 28/0, helm render test green, and scripts/check-new-values-keys.ts clean for the new target.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Re-reviewed commit bf57efb. The five original implementation findings are addressed. Server typecheck, formatting and lint pass; 81 focused tests pass. The follow-up comments cover two reproducible prerequisite-skip failures on Bun 1.3.14/1.4.0, missing enforced Helm CI coverage, runtime-mode overrides through extraEnv, and production-wiring assertions absent from the subprocess test.

Comment thread tests/helm-standalone.test.ts Outdated
}

test("the standalone chart target renders without Intelligence and refuses contradictions", {
skip: helm ? false : "helm is not installed",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This does not skip when Helm is absent: Bun's skip option is boolean, so the non-empty reason string is not treated as true. I reproduced the test invoking Bun.spawn("helm", ...) and failing with ENOENT on both the pinned Bun 1.3.14 and Bun 1.4.0. Please use skip: !helm (put the reason in the test name/comment, or use a conditional test.skip). Also, once this is fixed, the normal test CI job has no Helm and will skip this test while the chart matrix still omits standalone, leaving the new render untested in CI; please add standalone to that matrix.

const databaseUrl = process.env.DATABASE_URL;

test("src/index.ts boots standalone as a real process", {
skip: databaseUrl ? false : "DATABASE_URL is not set",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The same string-valued skip issue makes this run without a database instead of skipping. On both Bun 1.3.14 and 1.4.0, with no DATABASE_URL, I reproduced the child starting with an empty value and the test failing at DATABASE_URL must be configured. Please make this boolean (skip: !databaseUrl) or conditionally register test.skip.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

config.extraEnv is appended after the generated runtime variables (around line 319), and this chart intentionally lets the last duplicate name win. In standalone, an extra OPENBOT_RUNTIME_MODE, INTELLIGENCE_*, or COPILOTKIT_LICENSE_TOKEN entry bypasses template validation and can override/contradict the selected mode; the pod then crash-loops or boots in a different mode than the chart validated. Please reject these reserved names in standalone validation (and test the refusal), or render the mode-defining variables after the operator escape hatch.

expect(capabilities.mode).toBe("standalone");
expect(capabilities.durableHistory).toBe(false);

const chat = await fetch(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This subprocess test is the only one exercising production index.ts wiring, but it asserts only the chat route. The focused route tests manufacture 404s by calling createApp(config) without threadReader or routineStore, so they would still pass if index.ts accidentally supplied either collaborator again. Please probe /api/threads/... and /api/routines on this spawned server as well, so the two wiring fixes are actually regression-tested.

@IshmaelRogers

Copy link
Copy Markdown
Author

All four follow-ups addressed in 4bf956b (rebased onto #1's ed08c53).

1+2. The skips are real now — you were right on both: Bun's skip option wants a boolean and my reason strings meant neither test skipped at all. Both use test.skipIf(!prerequisite) and both were verified to genuinely skip: the helm test under a PATH with no helm, the boot test with DATABASE_URL= empty — 0 pass, 1 skip, 0 fail each.
3. extraEnv cannot smuggle the mode back in — standalone validation refuses entries naming OPENBOT_RUNTIME_MODE or any of the four Intelligence variables, with a message pointing at config.runtimeMode/config.intelligence.*. Chosen over reordering the render because last-wins is this chart's documented contract for every other variable, and the operator who needs the escape hatch should keep it — just not for the variables that define which runtime the pod is. Verified both ways live (OPENBOT_RUNTIME_MODE override refused; a harmless MY_HARMLESS_VAR still renders) and both directions are in the helm test.
4. The subprocess test probes the wiring itself — the spawned src/index.ts is now asked for /api/threads/thread-1/status and /api/routines alongside the chat route, all asserted 404: the focused route tests construct createApp without those collaborators themselves, so only this run proves index.ts's real wiring withholds them.

Still outstanding and still out of my reach: adding standalone to the chart job's workflow matrix — this credential lacks the workflow scope, so pushes touching .github/workflows/ are rejected outright. With the skip now honest, you're correct that the plain test job will skip the helm render; until the matrix line lands, the render coverage is the local/helm-equipped run of tests/helm-standalone.test.ts. The one-liner for whoever holds the scope: add standalone to the chart job's matrix.target list in .github/workflows/ci.yml.

Gate: format=ok lint=ok typecheck=ok, server suite 1686/0 (subprocess boot test included, now with the two extra probes), agent 31/0, helm render test green.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Round 3 re-review (4bf956b)

All substantive review findings from the prior rounds appear resolved on this head:

  • Explicit opt-in: OPENBOT_RUNTIME_MODE=standalone is required; partial Intelligence config still fails fast.
  • Surface gating: routineStore and threadReader are withheld in standalone; subprocess boot test now probes /api/threads and /api/routines (404) in addition to chat.
  • Helm: standalone values target, validation refusals (mode contradictions, routines CronJob, reserved extraEnv names), and conditional Intelligence secrets.
  • Import graph: Intelligence wiring behind dynamic imports; standalone avoids the MCP/eventsource crash.
  • Handoff: caps zeroed in standalone so the capability is not advertised undeliverably.
  • Test hygiene: test.skipIf replaces string skip values.

Local checks: standalone-runtime.test.ts + gateway tests pass; helm-standalone.test.ts and standalone-boot.integration.test.ts use correct skip guards (Helm/Postgres absent in this environment).

Non-blocking follow-up: CI chart matrix still omits standalone (noted in standalone-values.yaml); local render test covers it until workflow permissions allow the matrix update.

# render must produce a Secret without those keys. Routines stay off: the standalone server has
# no runtime to hand a firing to.
#
# NOTE FOR CI: this target must be added to the chart job's matrix in

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Standalone chart coverage looks good locally — validation refusals, extraEnv guards, and the subprocess boot test all check out on head 4bf956b. The one remaining gap called out here is still accurate: CI's chart matrix does not include standalone yet, so merges rely on this test (which skips when Helm is absent, as in the plain CI test job) rather than the workflow render/refusal job. Non-blocking once this lands, but worth tracking for when workflow permissions allow the matrix change.

@IshmaelRogers

Copy link
Copy Markdown
Author

Nothing new to fix here — round 3 raised no implementation findings on this branch. Rebased onto #1's fix (0a179b3, the idempotency-slot ownership bug) and re-gated; head is now 8c940e1, content-identical to 4bf956b apart from the new base.

Gate at 8c940e1 (local only — Actions remains blocked account-wide by org billing):

FORMAT=ok LINT=ok TYPECHECK=ok HELM=ok
bun test server/tests --timeout 60000 → 1687 pass, 0 fail
bun test tests/helm-standalone.test.ts → 1 pass, 0 fail (helm present here)
cd agent-langgraph && bun test tests/ → 31 pass, 0 fail

One thing worth naming rather than quietly re-running: the first full-suite run after the rebase had a single failure, in server/tests/routine-run-turn.test.tsone run id, everywhere > reaches the acquire, every renew and the cleanup, at expect(calls.renewed.length).toBeGreaterThan(1) (received 1). It is a pre-existing timing flake, not a regression from this stack: the test asks for more than one tick of a 2 ms heartbeat inside a 20 ms window, the file is untouched by either branch in this stack, it passes 3/3 in isolation, and the immediately following full-suite run on the same head was 1687 pass / 0 fail. This host is loaded enough to starve that window occasionally — the same reason three retention tests need --timeout 30000+ here. Flagging it because a 2 ms/20 ms assertion will keep flaking on any busy runner, and it is upstream code, so it is yours to decide whether to widen.

Still outstanding and still out of my reach: standalone in the chart job's matrix.target in .github/workflows/ci.yml. My token has no workflow scope, so pushes touching that path are rejected outright; tests/helm-standalone.test.ts covers the render and both refusals locally in the meantime, and skips (genuinely — test.skipIf) where helm is absent, as in the plain CI test job. It needs a maintainer with workflow permissions.

RuntimeCapabilities grows the second mode its own comment predicted. All
four INTELLIGENCE_*/COPILOTKIT_* values absent now means standalone - a
working deployment with the admin, people, computer and plugin surfaces,
and no chat runtime - rather than a refusal to boot. A partial set is
still refused, naming what is missing and both valid shapes.

- The chat runtime, threads and routines are unmounted in standalone, so
  those paths 404 by design instead of mounting doors that refuse; the
  handoff delivery loop says out loud that it stays off when configured on.
  createApp already treated all three as optional, so this is wiring, not
  route surgery.
- mountCopilotRuntime guards and narrows: mounting it without the contract
  fails in front of the deployer, per the old single-mode comment's promise.
- The Intelligence-bound wiring in index.ts (agent loader, buildAgentFor,
  the headless-turn pair, the runtime mount) moved behind dynamic imports
  inside the intelligence branch. Load-bearing, not tidy: that import graph
  reaches @modelcontextprotocol/sdk's CommonJS SSE client, whose require()
  of the ESM-only eventsource package crashed Bun at import time - in BOTH
  modes, on 1.3.14 and 1.4.0, before this change. Standalone now never
  evaluates the graph; intelligence mode loads it dynamically, which Bun
  handles, so both modes boot where neither did.
- Standalone is now an explicit choice: OPENBOT_RUNTIME_MODE=standalone,
  the same shape as OPENBOT_SINGLE_USER. Total absence of the Intelligence
  values without it stays a refusal to boot - a Kubernetes Secret that
  fails to mount must crash in front of somebody, not come up 'healthy'
  with the chat runtime quietly missing. Chosen standalone with
  Intelligence values set is refused as the contradiction it is.
- The routine surface is withheld in standalone: createApp gets no
  routineStore, so /api/routines is unmounted and no schedule can be
  enabled for a worker to dispatch into a 404. Routine tools follow.
- Handoff caps are zeroed at the source in standalone (warned when env
  explicitly set them), so the capability endpoint, the grant surface and
  the delivery loop all give the same answer instead of a warning only
  one of them reflected.
- app.ts no longer imports the intelligence client: the thread reader is
  built inside index.ts's intelligence branch (dynamic imports) and
  injected as createApp's trailing optional parameter; the standalone
  suite's runtime-mount test now imports copilot.ts dynamically too, and
  a subprocess boot test drives the real src/index.ts module graph.
- The Helm chart can render the mode: config.runtimeMode conditions the
  validation, the Secret's intelligence keys, and the env injection
  (OPENBOT_RUNTIME_MODE replaces the four INTELLIGENCE_* vars); a
  standalone ci values target plus a render test cover it, including the
  contradiction and routines refusals. Adding the target to the CI matrix
  needs the workflow permission and is left to a maintainer.
- Both prerequisite skips used Bun's skip option with a reason string,
  which is not treated as true - the tests ran and failed on ENOENT / an
  empty DATABASE_URL exactly as the review reproduced. Now test.skipIf,
  and both verified to genuinely skip without their prerequisite.
- config.extraEnv renders after the generated variables and the last name
  wins - deliberately, except for the variables that define which runtime
  the pod IS. Standalone validation now refuses extraEnv entries naming
  OPENBOT_RUNTIME_MODE or the four Intelligence variables, so the escape
  hatch cannot boot a pod in a mode the chart never validated. Harmless
  extraEnv entries still render; both directions tested.
- The subprocess boot test now probes /api/threads and /api/routines on
  the spawned server: the focused route tests build createApp without
  those collaborators themselves, so only this proves index.ts's real
  wiring withholds them.
@IshmaelRogers
IshmaelRogers changed the base branch from feat/bitmind-gateway to main September 2, 2026 20:15
@IshmaelRogers
IshmaelRogers merged commit 7876fdb into main Sep 2, 2026
@IshmaelRogers
IshmaelRogers deleted the feat/standalone-boot branch September 2, 2026 20:16
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.

2 participants