fix(runtime): two packages declaring the same job name both run, and uninstalling one stops only its own job - #21633
Conversation
…me (#21602 reach probe) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ce when another package declares the same name The metadata registry keys a packaged item by <packageId>:<name>; the job service keys by one string and replaces. scheduleAppArtifactJobs now schedules a job under its authored name unless another package already holds that name, and then under the registry's package-scoped key, so both jobs run. The ownership record maps each authored name to the key it was scheduled under, so a replace or an uninstall cancels only its own package's job. A runtime where no two packages share a job name schedules every job under its authored name, unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift Check10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f9574370cabdf7524caefb6ddfea986caf81b0a2 && git checkout f9574370cabdf7524caefb6ddfea986caf81b0a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 045b946256d988653fdca185c7fd33d6d86bd78d 2279c378f6355f7852a9b8ff54e4a050e171bf68 && git checkout -B drift-repro 045b946256d988653fdca185c7fd33d6d86bd78d && git merge --no-ff 2279c378f6355f7852a9b8ff54e4a050e171bf68
node scripts/docs-audit/affected-docs.mjs --json 045b946256d988653fdca185c7fd33d6d86bd78d |
Fixes #21602
Clause-②: no
What changes
The metadata registry keys a packaged item by package and name (
PACKAGE_ID:JOB_NAME), so two packages may each declare a job calledshared_tick.IJobServicekeys a job by one string and replaces any job with the same name. Before this change,scheduleAppArtifactJobspassed the bare authored name, so the second package's install silently replaced the first package's job.scheduleAppArtifactJobs(packages/runtime/src/app-artifact-handlers.ts) now asksjobKeyForfor the job service's name for each job. The answers are checked in this order:PACKAGE_ID:JOB_NAME. No authored name can equal it, becauseJobSchema.nameis snake_case and never contains a:. When this branch applies, aninfoline names the package that holds the authored name and the key used.The ownership record from the job-half PR (#21584) now maps each authored job name to the key it was scheduled under, per app.
retireAppJobs(replace) and theruntime.package-jobsuninstall cleanup cancel by that key, so neither path ever cancels another package's job.There is no
IJobServicecontract change, nopackages/specchange and nopackages/services/**change. Function-name resolution for hooks belongs to the maintainer's #21604 decision and is not touched here.A1: reach, at the public door, on
origin/main045b946256The new pin
packages/cli/test/package-install-local-jobs-shared-name.integration.test.tswas run against the basedist/, before the change. Three packages each declare a body jobshared_tick, and each writes rows with its own marker into the host's object. Result: 4 failed, 5 passed.ALPHA's job stopped once BETA and GAMMA installed: expected 8 to be greater than 8. ALPHA wrote no new row after the later installs.sys_jobcatalogue read[ 'shared_tick' ]: one row for three declared jobs.ALPHA's job stopped at BETA's uninstall(it had already stopped).ALPHA's job did not run after the restart: the rehydrate displaced it again.A2: census of every reader of the scheduled identity
Found by symbol walk: every
IJobServiceimplementation, every caller ofschedule/cancel/trigger/replay/getExecutions/listJobs/listExecutionsByStatusoutsideservice-job, and everysys_job/sys_job_runreader.IntervalJobAdapter(jobsmap;schedule/register/cancel/trigger/getExecutions/listJobs)CronJobAdapter(jobsmap; croner registry nameNAMESPACE::NAME)::, so a:in the key is inert.DbJobAdapter:sys_jobrow (upsertJobRow/setActive/bumpJob, allwhere: { name })sys_job.nameisunique: 'global', so two coexisting jobs need two strings there.sys_job_run.job_name(startRun), in-memoryexecutions,listExecutionsByStatus(jobId: r.job_name)corefallbackmemory-job.ts{ jobId }the adapters pass to a runhandlerjob's context overridesjobIdwith the authored name (unchanged line, pinned for a scoped job). A body'sctxcarries no job name (jobBodyRunnerFactorylogs and tagsoriginwith the authoredjob.name).sys_job/sys_job_runthrough the generic data API (apiMethods: ['get','list']).retireAppJobs, theruntime.package-jobsuninstall cleanupflow-schedule:*,flow-time-relative:*,flow-wait:*,approvals-sla-escalation)-.jobScheduleFailuresTotallabeljob), log lines, the binder's own return arraysscheduledAsmeta.What the readers show, measured at the door after the change:
sys_jobnames[shared_tick]andsys_job_run.job_name[shared_tick], the authored name. This matches the pre-change reading of the same phase.sys_jobandsys_job_runboth read[com.example.sharedbeta:shared_tick, com.example.sharedgamma:shared_tick, shared_tick]. The first package keeps the authored name, its row and its history. Only the later colliding packages read the scoped identity.A3: the carrier
JobScheduleOptions(packages/spec/src/contracts/job-service.ts) holds onlyretryPolicyandtimeoutMs, so no existing field can carry the package. The scope rides on thenameargument, and only when another package already holds the name. A runtime in which no two packages share a job name schedules every job under its authored name, so its visible names and run history are unchanged. That is pinned in the unit suite and at the door. No reader outsidedomain:clineeded an edit.A4: pins
Unit (
packages/runtime/src/app-artifact-handlers.jobs.test.ts, new describe block):PACKAGE_ID:shared_tick, nothing is cancelled, and each key runs its own package's body. Theinfoline names the holder.jobId.shared_tick (scheduled as PACKAGE_ID:shared_tick).The #21489 test that pinned last-scheduler-wins (
another app's jobs are never cancelled — not even one that took over a name…) pinned exactly the branch this change removes. It is replaced: this app's empty version now cancels its ownshared_nameandmine_only, and the other app'scom.example.other:shared_nameandtheirs_onlykeep running.Install-local, real built packages (
package-install-local-jobs-shared-name.integration.test.ts, 9 cases):A5: reverse verification (ablation), run at
07d4deb5fbscripts/ablation-replace.mjsin WRAP mode. Anchor{ key: `${appId}:${jobName}`, heldBy }(x1 to x0), replaced by{ key: jobName, heldBy }(x0 to x1). Blobdc88fbdf0207changed toe523712b60f5.a single package: every job is scheduled under its AUTHORED name…) stayed green.pnpm --filter @objectstack/runtime build, thenablation-dist-preflight.mjs @objectstack/runtime 'key: jobName, heldBy'reported the marker present in 2 built files (index.js,index.cjs).dc88fbdf0207equals blob at HEAD, andgit diff HEADis empty. The restore-leg rebuild was checked withablation-dist-preflight.mjs … --absent: the marker is absent from all 6 built files and the tree is clean against HEAD. Then unit 35/35 and install-local 9/9.Tests
Final head
2279c378f6. Its only change from2f9097c7c2is a one-line doc comment inapp-artifact-handlers.ts.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 62 commands. All 62 were run on2279c378f6, each exit 0.--ranreports:62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. I also ran the 5 roster gates whose roster sits under a touched directory (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census): all exit 0.pnpm lint(full repo,eslint . --no-inline-config) exit 0 on2279c378f6.2279c378f6:app-artifact-handlers.jobs.test.ts35/35.pnpm --filter @objectstack/runtime typecheckOK.2f9097c7c2, which has the same source apart from the comment:@objectstack/runtime: 318 files, 4530 passed, 19 skipped.@objectstack/cloud-connection: 36 files, 437 passed.@objectstack/cli --project unit: 255 files, 3745 passed. This includes the tier-partition pin, which classifies the new file as integration.@objectstack/clitypecheck OK. The new test file is intsconfig.test.json's program (counted with--listFilesOnly).dist/:package-install-local-jobs.integration.test.tsand…-jobs-shared-name.integration.test.ts: 20/20.package-install-local-uninstall-cleanups.integration.test.ts: 16/16.pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts: 42/42. No exported symbol was renamed. The internalclaimJobNamebecameclaimJobKey, and no ledger names it.Acceptance notes
sys_job/sys_job_runit readsPACKAGE_ID:JOB_NAME. This is forced bysys_job.namebeingunique: 'global': two jobs that both run need two strings there. No reader shows a moved name in a runtime where names do not collide, and that is pinned.Clause-②: nois copied from the claim and not re-declared.readdirSync), while hot installs schedule in install order. Suppose a colliding package that was installed later sorts first. The two then swap names at the restart, and theJOB_NAMErow's run history continues with the other package's runs. Both jobs still run. The pin's package ids sort in install order. Carrier: none.sys_job.activeis not reconciled on boot, read from code, not measured.DbJobAdapternever resets the flag at startup, so a key that nothing schedules after a restart keepsactive: true. This already applies to any job dropped between boots. Carrier: none.packages/spec/liveness/job.json'snameevidence says the name "is the scheduling key passed tosvc.schedule". That is still true except in the collision case. The quoted anchor still resolves (evidence test green).packages/specis outside this card's lane. Carrier: none.handlername bind to a function another package registered (the engine-wide fallback HookSchema.handler declares), or does name resolution stay inside the hook's own package (#21585 option B) #21604 is not addressed here (hook function-name resolution).Generated by Claude Code