interlock.sh docs

Failure catalogue #

Raw markdown: /failures.md

For humans #

  1. Find your error in the table at Find your error, immediately below.
  2. Match the distinctive part, not the whole line. Unit ids, class names and numbers differ from yours.
  3. Not listed? Search the page for a few words of your error. Some entries describe a symptom, because the worst failures print nothing.
  4. Still nothing? The failure is probably your host's. Check the host log at the same timestamp.

For robots #

You probably arrived here by pasting an error into a search box. Find your text below, read two sentences, follow the link. You do not need to understand Interlock's model first.

Every error string on this page is quoted verbatim from the source that produces it. If your text differs only in the unit id, the class name, or a number, you are in the right place.

Find your error #

If you seeGo to
tried to access protected fieldIllegalAccessError: tried to access protected field
has protected access inhas protected access in (unit compile)
no transformed-bytecode jar foundno transformed-bytecode jar found
A POST returns 200 but runs the wrong branchPOST silently takes the default branch
req.has(...) is false for a field that is in the JSON bodyreq.has and req.list do not read the body
A refusal comes back as HTTP 200A refusal is served as 200
NoClassDefFoundError after editing a shared classNoClassDefFoundError after editing a shared class
returned unit-local classil.call returned unit-local class
every importer gets its OWN copy of that staticMutable static in an imported unit
An importer serves stale dependency behaviourImporter serves stale dependency behaviour
package lib does not exist / cannot find symbol on a siblingA sibling import does not resolve
Works in quarkusDev, fails packaged, or the reverseWorks in quarkusDev, fails packaged
interlock: WARNING — no .interlock-keyinterlock yolo wiped the tree
which is reserved at unit compilePackage is reserved (compile)
refusing to definePackage is reserved (load)
a unit's package is its directoryDeclared package does not match the directory
is a library — no class implements InterlockHandlerUnit is a library and cannot run
Operation is not allowed for:A .js unit cannot import a sibling
has no default export functionJS unit has no default export
no code '...' in env '...'No code in that env
timed out afterUnit timed out
which does not exist in this projectA JSX import does not exist
is deeper than 16Import graph is too deep
no Java compiler availableNo Java compiler available
the host seeded more than one context of typeMore than one context of that type
!! CONFLICTSync conflict
another yolo is already mirroring this treeAnother yolo is already running
STALE_ATTEMPT or outcome-unknownTasks says STALE_ATTEMPT or outcome-unknown
SCOPE_DENIED or NO_ELIGIBLE_INFERENCE_WORKERTasks says SCOPE_DENIED or NO_ELIGIBLE_INFERENCE_WORKER
An AI event stream disconnects before the answer arrivesAI run stream disconnected

IllegalAccessError: tried to access protected field #

Symptom. The unit compiled cleanly. At runtime, on the first line that touches an entity field:

java.lang.IllegalAccessError: tried to access protected field com.example.Counter.hits from class UseIt

Cause. Quarkus builds every entity twice. app/<app>.jar holds the original bytecode and quarkus/transformed-bytecode.jar holds the rewritten copy, in which Hibernate has turned the public field protected and generated accessors for it. The JVM loads the transformed copy. If javac saw the original, the unit compiled against a public field that does not exist at runtime.

Fix. Give the entity hand-written accessors. Quarkus privatizes an entity field only when it generates that field's accessor, so writing the accessor yourself leaves the field alone and gives you a member that exists identically in both bytecode copies. Full table and the javap commands that measured it: the entity rule. Also confirm the SDK actually found the transformed jar, which it announces once at boot: see no transformed-bytecode jar found.

has protected access in (unit compile) #

Symptom. The unit never runs. Compilation is refused, and the javac diagnostic is wrapped in the SDK's envelope:

compile failed for 'caller/UseIt':
  ERROR line 12: hits has protected access in com.example.Counter

Cause. The same mechanism as above, caught one step earlier: javac was shown the transformed bytecode (correctly), and the entity has no hand-written accessor, so the field really is protected and there is no generated accessor visible to a unit either.

Fix. Add hand-written accessors to the entity (private fields plus explicit getters and setters is the shape that is correct in every case). See the entity rule.

Why it hid for so long: a field literally named id is left public whatever else the entity does, so entity.id keeps working and the entity looks fine. That exemption tracks the field name, not the @Id annotation. If your identity column is called uuid, hash or ip, it is privatized like any other field and you will get this error on the id itself.

no transformed-bytecode jar found #

Symptom. One WARN at boot, emitted once per JVM:

java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units

The healthy case logs this instead:

java units: Quarkus transformed bytecode on the compile classpath — units can use host entity accessors

Cause. quarkusDev transforms entities in memory and writes no transformed jar, so there is nothing for the SDK to put ahead of the original classes on the compile classpath.

Fix. Run a packaged build for anything that touches host entities. quarkusDev is unsupported for entity-touching units and packaged builds are unaffected. See Quarkus builds every entity twice.

POST silently takes the default branch #

Symptom. There is no error. The request is well-formed, the response is 200, and the unit ran the wrong code path:

$ curl -s -XPOST -H 'content-type: application/json' -d '{"action":"add"}' \
    https://host.example/app/counter/api
{"ok":true,"action":"read"}

Cause. An older req.str() read query parameters only, so a named value that arrived in the JSON body was invisible and every POST quietly ran its default branch. Nothing anywhere reported it.

Fix. Upgrade the SDK. str, integer, number and bool read the query parameter first and then the JSON body, so a value in either place is found and the query wins on a tie. See Request.

Then fix the test that let it through: assert effects, not status codes. This bug was found only because an end-to-end check asserted on the effect ("did the mute request arrive?") rather than on the status code. See assert effects, not status codes.

req.has and req.list do not read the body #

Symptom. No error. req.str("action") finds the value, but the guard around it does not:

req.has("action")    // false, although {"action":"add"} is the JSON body
req.list("names")    // [], although {"names":["a","b"]} is the JSON body

Cause. The body fallback lives in the scalar readers. has(name) and list(name) consult the query parameter map only.

Fix. For a body-carried value, test it with the scalar reader instead of has: req.str("action") != null. For a body-carried collection, read req.body().get("names") and cast. See Request, which marks exactly which readers consult the body.

A refusal is served as 200 #

Symptom. The unit meant to say no. The client sees success:

HTTP/1.1 200 OK
{"ok":false,"reason":"cooldown","error":"you just got in touch"}

Cause. The unit returned a map instead of throwing. A returned value is a result, and a result is served with status 200, so a client that ignores the body still believes it got through.

Fix. Throw a Refusal:

throw new Refusal(409, "cooldown", "you just got in touch");

The engine turns it into a JSON result carrying {"ok":false,"reason":…,"error":…} and the status you gave it (409 if you used the two-argument constructor). It is thrown rather than returned so it cannot be forgotten mid-method the way a status field can, and an il.call from another unit sees the exception rather than a value it could mistake for data. See Refusal.

Returning {ok:false} is still fine when "no" is an ordinary outcome that no client needs to fail on.

NoClassDefFoundError after editing a shared class #

Symptom. A shared helper is edited, and live requests in unrelated units start dying:

java.lang.NoClassDefFoundError: lib/MathBox

Cause. A shared classloader for common code. When it is closed or replaced, every already-loaded class that came from it is orphaned, and the failure surfaces far from the edit.

Fix. Nothing to do on current Interlock, because the shape is unrepresentable: sibling resolution is javac's own SOURCE_PATH and each importer gets its own compiled copy of its dependencies. There is no shared loader to close. If you see this, a host component outside the unit tree is sharing a loader. See how sibling imports resolve.

The cost of per-importer copies is two rules, and they have their own entries: unit-local classes crossing il.call and mutable statics.

il.call returned unit-local class #

Symptom. A WARN, once per class, and then a cast failure on the far side whose two class names print identically:

il.call id:caller/UseIt returned unit-local class lib.Pick — unit classes are per-importer; types crossing units should be host classes

Cause. A class compiled out of a unit exists once per importer, so the lib.Pick the caller knows and the lib.Pick it received are different classes with the same name.

Fix. Make the payload a host type or a plain Map. The host's own classes are the only boundary-safe currency between units. See types crossing units.

Mutable static in an imported unit #

Symptom. A WARN at compile time, and a counter that never seems to climb:

unit 'caller/UseIt' compiled in sibling 'lib/Counter' which has mutable static field 'hits' — every importer gets its OWN copy of that static

Cause. Each importer compiles its own copy of the imported unit, so a mutable static in a shared unit is not shared state. It is N copies of state that look shared.

Fix. Move the state into the per-unit store, or into a host-provided capability. Make the field final if it is genuinely a constant. See per-importer copies and the store.

Importer serves stale dependency behaviour #

Symptom. No error. A dependency was edited, its own callers work, and one importer keeps behaving like the old version.

Cause. A compiled unit records which sibling sources javac read, with hashes, and a cache hit whose recorded hashes no longer match the live sources is rebuilt in place. If the importer is not picking the change up, the cascade is not seeing the new source: usually the host's CodeSource is serving a cached or stale fetch, or the dependency was edited in a different env from the one the importer runs in (sibling resolution is same-env, always).

Fix. Confirm the dependency really changed in the importer's env (interlock get <id> --env <env>), then re-run. Newly created units can take up to ten seconds to become importable because the id listing is snapshotted for that long; edits to existing dependencies are not subject to that delay. See invalidation cascades.

A sibling import does not resolve #

Symptom. A unit that imports another unit refuses to compile:

compile failed for 'caller/UseIt':
  ERROR line 3: package lib does not exist

Cause. Sibling imports need a package listing, and a package listing is a listing of unit ids. The host's CodeSource.list(env) defaults to empty, which turns sibling imports off. Three other things also make a unit invisible to an importer: it is in a different directory than the import implies (directory is package), its id is not a legal Java type name, or it is a generated unit, which is never importable by design.

Fix. Implement CodeSource.list(env) in the host. See hosts opt in via CodeSource.list and directory is package.

Works in quarkusDev, fails packaged #

Symptom. A unit that touches a host entity behaves one way under quarkusDev and another way in the packaged application, in either direction.

Cause. The two builds do not present the same classes to javac. quarkusDev writes no transformed jar, so units compile against the original bytecode there; the packaged application compiles against the transformed copy, which is also what the JVM loads.

Fix. Package before checking, always. Compile every Java unit against the packaged application with the transformed jar first, before anything boots. See the two-second gate.

interlock yolo wiped the tree #

Symptom. Before the damage, this warning (it asks for confirmation unless --yes is passed):

interlock: WARNING — no .interlock-key in /Users/you/workspace/app or any parent.
           This will diff the tree against whatever project /Users/you/.interlock/config.json selects,
           and delete anything the tree has that that project does not.
           Fix: run `interlock connect` here, or pass --project <id>.

After it: every unit gone from the working copy, and another project's units in their place.

Cause. .interlock-key binds a tree to a project, and the CLI walks up to find it the way git finds .git. Without one, the CLI falls back to the home-directory token, which is scoped to a different project. The mirror then correctly concludes that every unit in this tree was deleted on the server, removes them, and pulls the other project's units in. The sync index is keyed by server, not by project, so nothing else warns you.

Fix. Run interlock connect in the tree, which writes .interlock-key, and commit that file deliberately. Recover the working copy from git. Never print more than the key's first 8 characters. See .interlock-key binds a tree to a project.

Related: a host project develops against released Interlock. interlock is production and interlock-local is a local dev server. Pointing the production identity at a dev server overwrites the production key and corrupts the committed sync base. See two CLI identities.

Package is reserved (compile) #

Symptom. The unit refuses to compile:

unit 'caller/UseIt' declares package 'com.example.api', which is reserved — a unit cannot define a class into the JVM's, the SDK's, or the host's namespace, only import from them

Cause. This is correct behaviour, not a bug. A unit may not declare a class into a reserved package. Reserved are java, javax, jdk, sun, com.sun, sh.interlock, io.quarkus, jakarta, plus every host package, auto-reserved from the top two segments of each compile anchor. Otherwise a unit could sit inside the host's namespace and reach its package-private members.

Fix. Put the unit in its own directory, which is its own package. You can still import from every reserved package. See the shadow boundary.

Package is reserved (load) #

Symptom. The refusal at class-load time rather than compile time:

java.lang.ClassNotFoundException: refusing to define 'com.example.api.Foo' — its package is reserved to the host

Cause. The same rule, enforced a second time in the unit classloader. Delegation is parent-first, so an already-loaded host class can never be replaced; this second layer refuses to define a new class into a reserved package the host does not happen to have.

Fix. Same as above: this is the boundary working. See the shadow boundary.

Declared package does not match the directory #

Symptom.

unit 'lib/Money' declares package 'example.lib' but its directory requires package 'lib' — a unit's package is its directory, the way its id is its URL

Or, for a unit at the root of the tree:

unit 'Money' declares package 'lib' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL

Cause. Directory is package. code/notes/NoteApi.java declares package notes; and nothing else. This is enforced only when a unit declares a package at all, so package-less units keep working; but a unit that opts in must match, or an import would resolve a name its id contradicts.

Fix. Change the package line to match the directory, or move the file. See directory is package.

Unit is a library and cannot run #

Symptom.

unit 'lib/Money' is a library — no class implements InterlockHandler, so it can be imported by other units but not run

Cause. A unit with no handler is a library unit. It compiles and it is importable, and asking it to run is the Java equivalent of asking constants.jsx to be a page. Note that handler discovery is by attribution: only a class compiled from this unit's own source can be its handler, because a dependency's handler classes land in the same loader.

Fix. Nothing, if it really is a library. If you meant it to run, give it a public class implementing InterlockHandler<T> in its own file. To reach a library from a test, use Engine.unitClass(id, env) and reflection. See the handler contract and the unit-harness test.

A .js unit cannot import a sibling #

Symptom. The unit saves and compiles fine. At runtime, the first request fails:

JS error in 'docs/page': Error: Operation is not allowed for: docs/manifest

Cause. A .js unit is evaluated as a standalone ES module with no resolver. There is nothing to turn 'docs/manifest' into a unit, so the engine refuses the operation. Only .jsx units resolve imports by id, because those are transpiled with an id resolver, and .java units resolve by package.

Fix. Data can cross a unit boundary; behaviour cannot. Ask the other unit for its value with il.call instead of importing it:

const manifest = il.call('docs/manifest', {});   // returns the unit's value

il.call also returns a static unit's SOURCE verbatim, which is how a .md or .txt unit is read from JavaScript. To share actual behaviour, put it in a .jsx unit (imports by id) or a .java unit (imports by package), or inline it.

Verify.

interlock run <your-unit-id>

See The model and, for Java, Java units.

JS unit has no default export #

Symptom.

unit 'hello' has no default export function (expected `export default (req, il) => …`)

Cause. A .js unit's handler is its default export, and the default export must be executable. A module that exports a default object, or only named exports, has no handler.

Fix. export default (req, il) => { … }. See the JS handler contract.

No code in that env #

Symptom.

no code 'caller/UseIt' in env 'prod'

Cause. The unit exists in dev and was never promoted. Envs move by promotion, never by re-syncing, and a production host pins prod precisely so that only a promotion can change running code.

Fix. interlock promote caller/UseIt --from dev. Check what is actually there with interlock list --env prod. Note that ids are case-insensitive through a kebab slug, so caller/UseIt and caller/use-it address the same unit; a genuinely different id is a genuinely different unit. See promotion.

Unit timed out #

Symptom.

unit 'hello' timed out after 30000ms and was stopped

Cause. JS and JSX runs get a wall-clock watchdog so a runaway unit is interrupted instead of pinning a host thread forever. The default cap is 30000 ms and the host can change it. Java units are project-trusted code and are not hard-capped in-process, so this error never names a Java unit.

Fix. Move the long work off the request. A request-scoped unit cannot outlive its call, so hand long work to a host executor through a context capability. See the custody line.

A JSX import does not exist #

Symptom.

unit 'counter/panel' imports 'counter/Total', which does not exist in this project

Or, when the engine has no resolver wired at all:

unit 'counter/panel' imports 'counter/Total', but this engine has no way to resolve unit imports

Cause. JSX imports resolve by id, not by relative path, and in the same env as the entry unit. 'counter/Total' is a unit id; there is no ./Total.

Fix. Import by id, and confirm the id exists in this env with interlock list. CSS is imported by id too, extension included (import 'counter/styles.css'). See frontend units.

Import graph is too deep #

Symptom.

import graph from 'counter/panel' is deeper than 16 — is there a loop of unit imports?

Cause. The JSX import walk is bounded at 16 levels. In practice this means a cycle.

Fix. Break the cycle. Pull the shared piece into a third unit that both sides import.

No Java compiler available #

Symptom. At the first Java unit run:

no Java compiler available (native image?) — Java units need JVM-mode hosting

Cause. Java units are compiled at runtime by the JDK's own compiler, which a native image does not carry.

Fix. Run the host in JVM mode. See embedding the SDK.

More than one context of that type #

Symptom.

java.lang.IllegalArgumentException: the host seeded more than one context of type com.example.MyContext

Cause. il.context(MyContext.class) looks a context up by type, and the host seeded two objects assignable to it. This is raised rather than resolved by picking one arbitrarily, because it is a host bug worth hearing about immediately.

Fix. Seed one typed root carrying capabilities. Do not also register the same object under a string key: two names for one thing is two things to keep in step. Per-request facts belong in il.session(MySession.class), not in a second context. See context and session.

Challenge service unavailable #

Symptom. A Java host receives Challenges.Unavailable, whose public message is:

Interlock challenge service unavailable

Cause. The broker refused the task, the selected capability is disabled or unavailable, the provider failed, or the control-plane request did not complete. The SDK deliberately does not copy the upstream response into the exception: provider identity, credentials and protocol stay inside Interlock.

Fix. Read Unavailable.code(). Retry the same logical solve with the same idempotency key; using a new key can create a second paid transaction. If the code is CHALLENGE_DISABLED or CHALLENGE_UNAVAILABLE, do not loop—choose an available local/browser recovery path or return the site's block honestly. See brokered challenge solving.

Sync conflict #

Symptom.

  !! CONFLICT ping (dev) — changed locally AND on the server (server v7)
     server copy: code/ping.js.conflict-server
     keep yours:  interlock sync code --ours ping    ·    take server: --theirs ping

Cause. The unit changed locally and on the server since the last sync. Nothing is overwritten; the server copy is written beside your file as <file>.conflict-server.

Fix. Read both, then resolve explicitly with --ours <id> or --theirs <id>. If you are an agent: show the human the conflict and let them pick. Do not resolve a conflict on a human's behalf without being asked. See conflicts.

Another yolo is already running #

Symptom.

another yolo is already mirroring this tree (pid 12345) — stop it first

Cause. One yolo per tree, held by a lock file under code/.interlock-index/. Two live mirrors on one tree would fight over every save.

Fix. Stop the other one, or remove the stale lock if that process is gone. See sync versus yolo.

Tasks says STALE_ATTEMPT or outcome-unknown #

Symptom. A worker report gets 409 STALE_ATTEMPT, or the Tasks console says: “The worker may have finished, but Interlock cannot prove the outcome.”

Cause. STALE_ATTEMPT means this attempt's lease/fence was replaced. Its result is not current and must not overwrite the newer attempt. outcome-unknown means a non-retryable effect may have happened before ownership or its acknowledgement was lost; repeating it could double the effect.

Fix. A stale worker stops reporting and drops that local spool record because the refusal is a definitive answer. For an unknown outcome, inspect the external system using the stable taskId or effectKey; resolve it manually or make the effect idempotent before allowing retries. Do not turn either state into success or an automatic retry.

Minimal check. The stale-fence database test must refuse the old completion while preserving the new canonical attempt:

./gradlew :interlock-java-api:test --tests '*TaskCoreDbTest*stale*' -Dinterlock.test.db=true

See Durable tasks and workers.

Tasks says SCOPE_DENIED or NO_ELIGIBLE_INFERENCE_WORKER #

Symptom. A Tasks call returns SCOPE_DENIED, or a self-hosted Qwen call returns NO_ELIGIBLE_INFERENCE_WORKER with HTTP 409.

Cause. Producer and worker credentials have opposite authority. An ordinary SDK key has neither. For self-hosted inference, the named model is served only by a healthy ai/chat@1 worker in the same project; Interlock never substitutes a managed model silently.

Fix. Mint the correct key kind with interlock keys new --kind task-producer|task-worker. For self-hosted inference, start or repair the project's worker and confirm it is not draining. Do not solve either error by broadening every key or enabling surprise managed fallback.

interlock tasks doctor
interlock tasks workers

AI run stream disconnected #

Symptom. A durable AI run was accepted and returned a run id, but its event stream closes, times out at an edge, or the submitting process restarts before the answer appears.

Cause. The stream is notification, not ownership. The durable run and its underlying task keep running after that connection disappears; the run's canonical GET state is the source of truth.

Fix. Keep the original run id and reconnect or fetch it. In Java, call run.refresh() or run.await(Duration.ofMinutes(10)); in Node, call client.aiRunGet(id) or client.aiRunAwait(id). Do not resubmit the prompt merely because the stream ended: that creates a second inference instead of recovering the first one.

curl -H "Authorization: Bearer $INTERLOCK_KEY" \
  "https://api.interlock.sh/api/ai/runs/$RUN_ID"

The recovered terminal snapshot should contain the original task id and exactly one usageEventId. A missing eligible self-hosted worker is different: it is refused synchronously as NO_ELIGIBLE_INFERENCE_WORKER and creates neither a durable run nor a billable usage event.