Skip to content

fix(plugin-workers): a compiled job that does not reach the runtime registry fails at startup - #976

Merged
rickylabs merged 2 commits into
mainfrom
fix/workers-generated-job-registry-load
Jul 31, 2026
Merged

fix(plugin-workers): a compiled job that does not reach the runtime registry fails at startup#976
rickylabs merged 2 commits into
mainfrom
fix/workers-generated-job-registry-load

Conversation

@rickylabs

Copy link
Copy Markdown
Owner

Summary

A job compiled into .netscript/generated/plugin-workers/job-registry.ts could fail to reach the running worker runtime, surfacing as oRPC NOT_FOUND at triggerJob time — in one report after the caller had already committed its own writes. This makes the generated registry load through a single resolver, and turns "registry exists but did not reach the runtime registry" into a startup failure instead of silence.

Scope

Root cause — wider than the issue as filed

The issue has no comments, so the mechanism was re-derived from source. The component the issue names was the one that worked. workers-api (services/src/main.ts) resolved the registry correctly via projectFileUrl. Three of the four workers entrypoints did not:

  1. bin/combined.ts resolved one directory short of the project root. It built new URL('../../' + WORKERS_JOB_REGISTRY_PATH, import.meta.url). From <root>/plugins/workers/bin/, two ../ reach <root>/plugins/, not <root>/. Verified by evaluation rather than inspection:

    combined.ts resolves to: file:///proj/plugins/.netscript/generated/plugin-workers/job-registry.ts
    expected               : file:///proj/.netscript/generated/plugin-workers/job-registry.ts
    
  2. bin/worker.ts and bin/scheduler.ts never loaded the registry at all. Both call startWorkerProcess() / startSchedulerProcess() with no options, and registerStaticJobDefinitions(registry, undefined) returned immediately.

  3. Every miss was indistinguishable from "this project has no jobs." loadGeneratedJobRegistry mapped Deno.errors.NotFound to {} and returned definitions: undefined for an unrecognized module shape; registerStaticJobDefinitions opened with if (!definitions?.size) return;. Absent file, wrong path, wrong cwd and malformed module all produced the same observable — a running worker with the health-check job and nothing else, and no log line anywhere.

So whether a user's job was dispatchable depended on workers-api starting in the right directory and winning the race against the first trigger. That is the "returned after the parent record had already been written" shape in the report, and it explains all three symptoms from one mechanism — including "handler present, dispatcher still reporting 'not found in registry'": WorkerPool sets fallbackToDynamicImport: true, so the handler always resolved from workers/jobs/; it was the definition that was missing.

What changed

  • One resolver. resolveGeneratedJobRegistryUrl() in src/runtime/generated-jobs.ts is now the only place the registry path becomes a URL.
  • Three outcomes, not one. Loading returns absent (no compiled registry — legitimate, stays non-fatal) or loaded, and throws GeneratedJobRegistryError when a registry exists but exposes no usable definitions or fails to import.
  • Registration is verified by read-back. registerGeneratedJobRegistry re-reads every declared id out of the runtime registry after writing and throws naming the missing ids. Counting successful writes would not catch a write that reports success but does not land — which is the failure users actually saw.
  • Every outcome is reported. describeGeneratedJobRegistry emits one startup line naming the resolved path, and the cwd when absent. An off-by-one path is now visible in seconds.
  • Background entrypoints load by default. startWorkerProcess / startSchedulerProcess / startCombinedProcess resolve, load, register and verify when the caller supplies no definitions. bin/worker.ts and bin/scheduler.ts are unchanged and fixed by this.
  • The generated glue stops duplicating the loader. runtime.stub.ts emitted workers/runtime.ts carrying its own inline copy of stat/import/instanceof Map logic into every scaffolded project — a second implementation is how the two drifted. It is now a call to startCombinedProcess().

Regression guard

The existing checks looked just past this. services/src/generated-jobs_test.ts asserted that registerGeneratedJobDefinitions "tolerates a missing generated registry" — the silent degrade was encoded as the desired contract — and it built the registry URL itself, so it never exercised the resolution that was broken. tests/cli/registry-compiler-golden_test.ts locks the emitted registry byte-for-byte; all three reporters got past it, because the file on disk was correct every time.

plugins/workers/tests/runtime/generated-jobs_test.ts adds 8 guards, the load-bearing one being a source scan asserting no workers entrypoint resolves the registry path itself. Run against the pre-fix bin/combined.ts:

no workers entrypoint resolves the generated registry path itself ... FAILED
error: AssertionError: bin/combined.ts builds its own generated-registry path
       (matched /new URL\([^)]*WORKERS_JOB_REGISTRY_PATH/);
       use resolveGeneratedJobRegistryUrl() instead.

and against this branch: ok. The tolerates-silence test is replaced by one asserting absent is reported, plus one asserting a compiled job that does not register now rejects. All wired into deno task test; no new task needed.

Validation

Gate Command Result
Format deno task fmt:check PASS — 1870 files, 0 findings
Lint deno task lint PASS — 1725 files, 0 occurrences
Type check deno task check PASS — 2458 files, 0 occurrences
Tests (workers scope) deno test plugins/workers packages/plugin-workers-core PASS — 76 passed, 0 failed
Tests (new/changed) deno test .../tests/runtime/generated-jobs_test.ts .../services/src/generated-jobs_test.ts PASS — 11 passed, 0 failed
Doctrine fitness deno task arch:check PASS — exit 0, workers FAIL=0 (warnings all pre-existing)
Code quality deno task quality:scan PASS — exit 0
Doc lint run-deno-doc-lint.ts --root plugins/workers PASS — 23 privateTypeRef vs 24 on main, 0 missing JSDoc

Not run: deno task e2e:cli run scaffold.runtime. The archetype matrix marks runtime/Aspire validation required for Archetype 5, and this PR changes plugin scaffold output (runtime.stub.ts), so the release-gate class applies. It needs a live Aspire + container graph that this session does not have. Flagging rather than silently skipping — it should run before merge. Note it would not have caught the original defect: its workers probe exercises workers-api, the one path that resolved correctly.

Public surface

Additive on @netscript/plugin-workers/runtime: resolveGeneratedJobRegistryUrl, registerGeneratedJobRegistry, describeGeneratedJobRegistry, GeneratedJobRegistryError, GeneratedJobRegistryStatus, StaticJobDefinitionRegistrar (was private; it is why doc-lint improved). Two widenings: GeneratedWorkersJobRegistry gains status + url (existing fields unchanged, so { definitions, registry } still destructures), and registerStaticJobDefinitions returns a count instead of void. The only in-repo consumer of the changed shape was bin/combined.ts.

Deliberately not changed

  • WORKERS_API_URL is declared as a literal string, not an Aspire service reference (src/aspire/workers-contribution.ts declareEnv). This is the likely mechanism behind the "workers missing ServiceReferences" symptom reported in the same issue. Different file, different fix, different blast radius — worth its own issue rather than folding into a registry fix.
  • WorkersAspireContribution registers workers-combined alongside both workers-scheduler and workers-worker, so the scheduler and worker each run twice. Noted, untouched.
  • absent stays non-fatal. A project that has not run the compiler yet is legitimate; making it fatal would break every such workspace.
  • Projects scaffolded before this change keep their inline copy of the loader in workers/runtime.ts. That copy uses projectFileUrl and resolves correctly, so they are not broken — but they do not gain the loud startup check until the glue is regenerated.

Harness

  • Run dir: .llm/runs/fix-workers-generated-job-registry-load--fix-951/
  • Phase: IMPL. Single-session issue fix — the generator ran its own gate set; no separate PLAN-EVAL / IMPL-EVAL session was dispatched. Recorded in drift.md D3 rather than left implicit.

Drift / Debt

  • D1 — the issue's stated mechanism was one of four silent-load paths, and not the primary one; fixed all four.
  • D2 — second distinct defect found (Aspire ServiceReferences), filed not folded.
  • D3 — single-session run, no independent evaluator.
  • D4 — already-scaffolded projects keep the old inline glue.

No new debt entries. The run removes an implicit one: a duplicated loader with divergent path resolution and no failure signal.

…egistry now fails at startup

Three of four agents building on beta.11 hit the same wall: a job compiled into
.netscript/generated/plugin-workers/job-registry.ts that the worker runtime never
registered, surfacing as oRPC NOT_FOUND at triggerJob time — in one case after the
caller had already committed its own writes.

The registry was loaded on a silently-best-effort path that three of the four
workers entrypoints got wrong:

- bin/combined.ts resolved `../../<path>` against its own module URL, landing in
  <root>/plugins/ instead of <root>/ — one directory short, so it never found the
  file;
- bin/worker.ts and bin/scheduler.ts never loaded the registry at all;
- loadGeneratedJobRegistry mapped every miss — absent file, wrong exports, wrong
  cwd — to `{}`, and registerStaticJobDefinitions returned early on empty, so all
  of them were indistinguishable from "this project has no jobs".

Only workers-api resolved correctly, which made the whole feature depend on one
resource starting in the right directory and winning the race against the first
trigger.

resolveGeneratedJobRegistryUrl is now the only resolver, loading reports absent vs
loaded and throws when a registry exists but is unusable, and registration is
verified by reading every declared id back out of the runtime registry. The
generated runtime glue stops carrying its own copy of the loader.

The regression guard is a source scan asserting no entrypoint resolves the registry
path itself; it fails against the pre-fix bin/combined.ts and passes after.

Closes #951
@rickylabs rickylabs added this to the 0.0.1-beta.12 milestone Jul 31, 2026
@rickylabs rickylabs added type:fix status:impl area:plugins plugins/* and plugin-core packages labels Jul 31, 2026
@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: IMPL]

One slice landed: the generated workers job registry now has one resolver, and a compiled job that does not reach the runtime registry is a startup failure rather than a NOT_FOUND at dispatch time.

Slices

  • S1 — loader (single resolver, absent/loaded/throw, read-back verification), entrypoints (bin/*, services/src/main.ts, generated glue stub), regression guards — 26a161d0

Landed as one commit: the loader signature change and its consumers are not independently shippable, and splitting would leave main type-broken between them.

Root cause

bin/combined.ts resolved new URL('../../' + WORKERS_JOB_REGISTRY_PATH, import.meta.url), which from <root>/plugins/workers/bin/ lands in <root>/plugins/ — one directory short. bin/worker.ts and bin/scheduler.ts never loaded the registry at all. And loadGeneratedJobRegistry collapsed every miss to {}, so none of it was observable. workers-api — the component the issue names — was the only one that resolved correctly.

Gate evidence

Gate Result
deno task fmt:check PASS — 1870 files, 0 findings
deno task lint PASS — 1725 files, 0 occurrences
deno task check PASS — 2458 files, 0 occurrences
deno test plugins/workers packages/plugin-workers-core PASS — 76 passed, 0 failed
deno task arch:check PASS — exit 0, workers FAIL=0
deno task quality:scan PASS — exit 0
run-deno-doc-lint.ts --root plugins/workers PASS — 23 privateTypeRef (24 on main), 0 missing JSDoc
deno task e2e:cli run scaffold.runtime NOT RUN — needs a live Aspire graph unavailable in this session

The regression guard is a source scan asserting no workers entrypoint resolves the registry path itself. Verified failing against the pre-fix bin/combined.ts and passing on this branch — the before/after output is in the PR body.

Next

  • scaffold.runtime E2E before merge (owner / CI). It would not have caught the original defect — its workers probe exercises workers-api, the path that already worked — but it is the required Archetype 5 runtime gate and this PR changes plugin scaffold output.
  • IMPL-EVAL in a separate session; this run had no independent evaluator (drift.md D3).

@rickylabs
rickylabs merged commit ac7a8f6 into main Jul 31, 2026
13 checks passed
@rickylabs
rickylabs deleted the fix/workers-generated-job-registry-load branch July 31, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:plugins plugins/* and plugin-core packages status:impl type:fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(plugin-workers): generated job registry is not loaded by the worker runtime — triggerJob returns NOT_FOUND

1 participant