fix(runtime,cloud-connection)!: a job's sandboxed body is scheduled on every door, and install-local refuses an enabled job with no body (#21489) - #21584
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…r; install-local refuses a job with no body (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ndering; changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…job loop is cited where it now lives Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…job half Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 4 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 149 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 cf86783dfea69686e166f9035db86da5fabcfa00 && git checkout cf86783dfea69686e166f9035db86da5fabcfa00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d 650ff1e486a5b956106904631bb49f0b805693bd && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff 650ff1e486a5b956106904631bb49f0b805693bd
node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d
|
…job, and a control package (measures the pre-fix reach) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… drops them The binder's job half records which jobs each app scheduled and cancels the ones a new version no longer schedules; the runtime.package-jobs uninstall cleanup, registered on the protocol's registry when a package's jobs are first scheduled, cancels an uninstalled package's jobs on every uninstall door. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ll that drops them Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…n after the uninstall withdrawal still shows Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ery door runs a job body 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
Contract reviewServed-tier: Contract review seat, an isolated subagent of the Gate verdicts on the head (latest run per name, all 35 completed): 31 Governed surfaces: none in the file list (no ① Derived judgmentsEach accept-set or public-surface change the diff implies, judged against ruling E + C ( The install-local door (
The binder's job half ( The sandbox ( Public surface of The CLI (
Docs and checklist The ruling's four pins are all present at the head: a body job scheduled on install-local hot and after a restart (CLI integration, cloud-connection install and rehydrate); a handler-only enabled job refused; a package with no jobs installs unchanged; the boot path still schedules a Nothing under ① is judged WRONG. ② Semver levelClause-②: yes (narrowing)
③ Boundary flagsEvery dev flag and Round 1 deviations (
Round 1 open question (four published texts made false): ANSWERED. First routed A by the REWORK, then reversed by the amendment so the texts ride this PR; all four are in the diff (① items 19 and 21) with no build route in any wording. Round 2 deviations ( Out-of-scope findings, each dispositioned:
Cross-lane state read: no objection from Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21489
Clause-②: yes (narrowing)
This executes ruling E + C (record
5964305303, maintainer 「jobs同意」), as the card's runtime half and C, with the scope the erratum5968972501restates: a jobbodyis authored as data andos buildnever mints one (#21540 ruled C, record5968961157). Nothing here adds a build or lowering route.body(JobSchema.body, the hook body shape) is scheduled on every door that brings an artifact in: the boot (a config, oros start --artifact), and install-local on install and on every rehydrate. With bothbodyandhandlerdeclared, thebodywins.body. The answer is422withVALIDATION_ERROR, it names each job and its handler, and it gives the remedy: give the job abody, or boot it withos start --artifact. Nothing is registered, persisted or scheduled.os package installprints a refusal's code beside its status, for every refusal alike.handlerkeeps working on a config or--artifactboot (the control pin).5969239835). A package's jobs stop with it. Uninstalling a package cancels its scheduled jobs, on install-local'sDELETEand on the protocol's package uninstall alike, and a reinstall cancels the jobs its new version drops.5969471197(seat-directed). The texts this landing makes false ride this PR:JobSchema.body's describe,defineJob's TSDoc example, the regeneratedcontent/docs/references/system/job.mdx, and the callout and example comment incontent/docs/automation/jobs.mdx.What changes
The one binder's job half (
packages/runtime/src/app-artifact-handlers.ts):scheduleAppArtifactJobs(ctx, bundle, { appId, ql, source })is the one place a declared job becomes a scheduled one. It holds the loop that used to sit inline inAppPlugin.start: the deployment switch ([Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396), the job-service probe, theenabledskip,toBoundaryJobSchedule, theretryPolicy/timeoutMsthreading, and the failure posture (error level plusjobScheduleFailuresTotal).bodyis bound throughjobBodyRunnerFactory. Otherwise thehandlerresolves againstfunctions, with the in-processJobHandlerContextas before. A body that cannot be bound (an L1 expression, or abody.timeoutMs) schedules nothing, and never the handler beside it.AppPluginonkernel:ready, and install-local'sbindArtifactHandlers, which runs on the install route and on the rehydrate.bindAppArtifactHandlersfor one reason, timing. The boot binds hooks and actions instart()but schedules jobs once the kernel is ready, while install-local's doors are already past that point. One implementation, two moments, no per-door copy.collectJobsWithoutBody(bundle)names the enabled jobs with nobody. It is the judgement C refuses on, read from the jobs the binder schedules.A package's jobs stop with it (
app-artifact-handlers.ts, patch round 1):IJobService.cancel, the verb every adapter implements: the cron adapter stops its timer, and the DB adapter also marks thesys_jobrow inactive. That covers a job the new version drops, disables or can no longer run. A version with no jobs cancels them all. A cancel that throws is logged aterrorand the name stays on the record for the next attempt.runtime.package-jobs, through the protocol's existingregisterUninstallCleanup([finding] An install-local uninstall (DELETE /api/v1/marketplace/install-local/:id) leaves the package's permission sets in sys_permission_set: the "no ghost grants" uninstall cleanup never runs on that door #21490). It cancels the package's recorded jobs. The protocol'sdeletePackageand install-local'sDELETEboth run every registered cleanup with the package id, so both stop the jobs with no per-door copy, and the outcome rides the response'scleanups. A job it could not cancel is an outcome (success: false, naming the job), never a throw. Nometadata-protocolfile is edited.The sandbox job origin (
sandbox/script-runner.ts,sandbox/quickjs-runner.ts,sandbox/body-runner.ts):ScriptOrigin.kindgains'job'.QuickJSScriptRunnergainsjobTimeoutMs, default 5000 ms of CPU, like an action body.resolveTimeoutnow picks a default per kind instead of hook-or-else. There is no env override: a job's owntimeoutMs(uncapped) is the declared place to raise it.(ctx)wrapper hooks use; a job has no input.jobBodyRunnerFactorypasses the job'stimeoutMsasopts.timeoutMs, the one limitJobSchema.timeoutMsstates.jobBodyRunnerFactoryreads the body's return as aJobRunOutcome, in the declared shape only.jobBodyRunnerFactoryservesctx.apithroughbuildSandboxApi, like every body's, under{ isSystem: true }. A job has no caller: an action body with no caller gets the same envelope, and ahandlerjob's rawqlamounts to it. The stored-metadata write refusal still applies.install-local (
packages/cloud-connection/src/marketplace-install-local-plugin.ts): the refusal sits as step 1c, beside the id gate. That is ahead of the conflict check, the posture gate, the hot-register and the ledger write. Rehydrate is not gated, for the id gate's reason. An entry an older build installed still rehydrates; its handler-only job is reported atwarnand not run.CLI (
packages/cli/src/commands/package/install.ts): the generic refusal branch printsInstall failed (STATUS CODE): MESSAGE. It wasInstall failed (STATUS): MESSAGE, which dropped the code.Spec ledger (
packages/spec/liveness/job.json,state-counts/job.mdregenerated): see the deviations below. Thejob.bodychildrenlanguage,source,capabilitiesandmemoryMbflipplannedtolive.authorWarn/authorHintare dropped, as the row's own carrier note prescribed for this card's commit.body.timeoutMsstaysplanned(refused). The five rows that citedapp-plugin.ts#startfor the moved loop are repointed toscheduleAppArtifactJobs.Measurements
A4, reach at the public door, before the fix (base
bd70706713). The composed pinpackages/cli/test/package-install-local-jobs.integration.test.tsran unchanged against the base build: 4 red, 2 green.Package installed into the running kernel. It was never scheduled.os start --artifactof one artifact carrying both forms plus its runtime module: the handler job ran (rows written) and the body job wrote 0 rows.After the fix: 6 of 6 green, at
f99d6dcd39and again at headc866c5ac9d.A1, the pointer's facts, re-measured at
bd70706713:AppPlugin#startresolvedfnMap[job.handler]only (app-plugin.ts:1178). A body-only job was skipped at warn, and with both keys presentbodywas ignored. Now the body binds, and wins (pins below).ScriptOrigin.kindwashook | action(script-runner.ts:118;body-runner.ts:120and:228), andresolveTimeoutdefaulted everything non-hook to the action budget. Now'job'has its own default.job.timeoutMsreaches the runner asopts.timeoutMs. Pinned: a spinning body withtimeoutMs: 40rejects withjob 'spin_job' exceeded CPU budget of 40ms, and the adapter receives{ timeoutMs: 40 }.Object.keys(ctx)inside the VM:api,logandcrypto, each behind its capability token.input,previous,userandsessionare present andnull; the sharedinstallCtxinstalls them for every body. There is nojobIdand no triggerdata, even when a manual trigger passes data.ctxis not widened.A2, the one binder: see above.
Patch round 1, measured at the public door before the cancellation (the extended pin at
37c472764f, whose code wasc866c5ac9d): 2 red, 9 green.DELETE(200), the uninstalled package's body job kept writing: 38 to 42 rows in 4 s.After the cancellation: 11 of 11 green. After PR #21581 landed, an uninstalled package's own object stops answering, so the pin's packages write into an object the host artifact owns; a run that should have stopped still shows there.
A3, codes.
VALIDATION_ERROR/ 422 is an existing member of theErrorCodeunion (the standard catalog), so this is notPENDING LEDGER CODEand nothing under the error-code ledger is edited.body.error-code-ledger.zod.ts, "Registering a new code"). This follows PR fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) #21563'sPERMISSION_DENIEDreasoning.PLUGIN_MANIFEST_INVALIDwas rejected because it would be untrue: the manifest is valid, sinceos validatepasses it andos start --artifactruns it.VALIDATION_ERROR(standardErrorCodeForHttpStatus), so code and status agree.JOB_WITHOUT_BODY_REFUSAL_CODE) plus a spec-lane ledger row.A5, CLI rendering. Before:
Install failed (422): MESSAGE, with the code dropped. That was a rendering gap, so the generic branch now names the code for every refusal. There is no case per code, and an envelope with no code prints the status alone. Pinned by unit and integration tests.A6, the sibling (hooks). Measured once at the public door. A package with a hook in the deprecated
handlerform and no function installs with exit 0 (Package installed into the running kernel), and the hook never fires: an inserted row keepslegacy: null, while a body-hook control on the same object stampedbodied: yes. The only trace is a server-sideWARN [hook-binder] skipping hook with unresolved handler. That is silent at the door. Reported for the seat to file; not fixed here.Pins
packages/runtime/src/app-artifact-handlers.jobs.test.ts(23, real QuickJS) covers:ctx.apias{ isSystem: true };body.timeoutMsare not scheduled, and never the handler beside them;timeoutMsreaches the adapter and bounds the run;timeoutMs, the runner's JOB default applies (not the hook's or the action's);JobRunOutcomeshape;ctxsurface has nojobIdordata;collectJobsWithoutBody;AppPluginschedules a body-only job onkernel:ready;error;runtime.package-jobsis registered once per protocol, cancels every job of the uninstalled package and none of another's, is a no-op for a package that scheduled nothing, and reports an uncancellable job assuccess: false.packages/cloud-connection/src/marketplace-install-local-jobs.test.ts(8, real runtimedist):VALIDATION_ERROR, names the job, the handler and both remedies, and leaves nothing registered, persisted or scheduled;DELETEcancels the uninstalled package's job through the cleanup, withruntime.package-jobson the response'scleanups, and a control package's job stays scheduled;packages/cli/test/package-install-refusal-rendering.test.ts(3, unit).packages/cli/test/package-install-local-jobs.integration.test.ts(11, integration): install, restart, refusal, the control on both forms, and, in the patch round: after theDELETE, no further row hot and none after a restart; after a dropping reinstall, the same for the dropped job while the kept job runs on; and another package's job running throughout.Reverse verification (ablation), fix committed first
Every leg ran through
scripts/ablation-replace.mjsin wrap mode. In each, the anchor went from 1 to 0 on disk and the blob changed. The marker was proven indist/byablation-dist-preflight.mjs(present on the mutate leg,--absentplus a clean tree after the rebuild on the restore leg). The restore was proven by blob equal to HEAD and an emptygit diff HEAD.if (job.body) {inscheduleAppArtifactJobs, runtime rebuilt):collectJobsWithoutBodyand runner-default cases stayed green);withoutBody.lengthguard of step 1c in install-local, cloud-connection rebuilt):await svc.cancel(name);inretireAppJobs, runtime rebuilt), atbb25c4992e:DELETE, reinstall) / 6 green;ensureJobUninstallCleanup(ctx, jobService);, runtime rebuilt): only the uninstall pins went red. Runtime 4 red / 19 green, cloud-connection 1 red / 7 green, CLI integration 1 red (uninstall hot) / 10 green. The reinstall pins stayed green, so the two mechanisms are pinned apart.650ff1e486(after PR fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel #21581's withdrawal landed, asABLATION_21489_E): runtime 6 red, cloud-connection 2 red, CLI integration 2 red (uninstall hot 38 to 42 rows, dropped job 16 to 20). The withdrawal alone does not stop the job.Tests and gates
Final head
650ff1e486, which mergesorigin/mainafter PR #21581 landed (no conflict):@objectstack/runtimepnpm test: 317 files, 4467 passed, 19 skipped.@objectstack/cloud-connectionpnpm test: 35 files, 428 passed.@objectstack/cli--project unit: 254 files, 3721 passed.@objectstack/cli--project integration, the four install-local pins (jobs, handlers, boot-steps, uninstall-cleanups): 4 files, 51 passed. The rest of the integration tier is declared to CI.@objectstack/specpnpm test: 606 files, 17952 passed.typecheckgreen for runtime, cloud-connection and cli.check:generatedafter the describe edit: onlycheck:docswas stale.--fixregeneratedcontent/docs/references/system/job.mdxalone, and only the describe sentence moved.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 118 families (the docs and spec families joined with the amendment), all run at650ff1e486, every one exit 0. The--ranreconciliation reads 118 run, 0 NOT-MEASURED, with exit codes recorded.pnpm lint(full repo): exit 0 at650ff1e486.Deviations and file surface
packages/spec/liveness/job.jsonandstate-counts/job.mdwere edited, although the dispatch keeps this lane out ofpackages/spec. Moving the job loop out ofAppPlugin.startturnedcheck:liveness, a required gate, red:job/retryPolicyandjob/enabledcitedapp-plugin.ts, which no longer names them. The gate's prescription is to repoint. The ledger's ownjob.bodycarrier note designates this card's commit for theplannedtoliveflip. Leftplanned, the publishedauthorHintmakesos validateprint a false warning. Measured with a config declaring a body job,os validateprintedjob 'vj_tick_body': sets body.source but this job property is planned ... (not read YET)before this edit, and prints no such warning after it. No Zod schema, no error-code ledger and no generated docs were touched. The spec package rides the changeset asminor, becauseliveness/is in itsfiles[].docs/qa/platform-checklist/areas/integration-system.json: one source anchor repointed (app-plugin.ts#handlertoapp-artifact-handlers.ts#scheduleAppArtifactJobs).check:platform-checklistwent red on the move.packages/runtime/src/sandbox/quickjs-runner.ts(the per-kind default and the wrapper) andpackages/runtime/src/index.ts(exports) were outside the expected list.5969471197:packages/spec/src/system/job.zod.ts(theJobSchema.bodydescribe sentence and thedefineJobTSDoc example comment, text only, no shape change), the regeneratedcontent/docs/references/system/job.mdx, andcontent/docs/automation/jobs.mdx(the callout and the example comment, plus one sentence on uninstall and reinstall). No wording names a build or lowering route.Acceptance notes
allowRuntimeCreate: falseforjobis justified byhandleralone. A runtime-authored job with abodywould now be runnable in principle, but no door schedules a runtime-authored job; the binder schedules artifact jobs.body-runner.ts's job factory is not exported from@objectstack/runtime's root, unlike the hook and action factories; it has no consumer outside the binder.os package installrenders every 404 as "install-local endpoint not found", including a catalog 404 (CLOUD_FETCH_FAILED, a package missing from the catalog).engine.resolveFunction, so it could bind to a same-named function another app registered. The silent drop of a handler-form hook is filed as [finding] os package install accepts a package whose hook uses only the deprecated function-name handler (no body), answers "installed", and the hook never fires: install-local drops it with a server-side warn only #21585.packages/qa/dogfood/test/expression-conformance.ledger.tsprose still namesruntime/app-plugin.ts startas thetoBoundaryJobSchedulecall site.os buildcannot lower a job's handler into the newJobSchema.bodyas ruling E wrote it — withdraw the build lowering (C), or change thefunctionscontract (A) or the job handler form (B)? #21540 is not addressed here (ruled C; see the erratum above).Generated by Claude Code