Repository navigation
Description
Follow-up to objectstack#12938 / PR objectstack#12952, found while running the
consumer acceptance that PR named (objectstack-ai/cloud#1710).
Measurement
ObjectStack Cloud's migrate-control-db.yml, plane=staging, apply=false, against
the real Neon staging control plane, framework pinned at 15d55fb2430f (carries
fc8a33935). Run: objectstack-ai/cloud actions run 33182129326.
The command is os migrate plan inside docker run --rm, cwd
/repo/cloud/apps/cloud (an objectstack.config.ts is present, so the host
stack is composed). The CLI finished and said so:
15:03:52.522 17 change(s): 0 safe, 0 needs-confirm, 17 destructive
15:03:52.522 Apply with: os migrate apply (add --allow-destructive for drops / tightenings)
15:03:52.522 4316ms
15:03:52.517 INFO Graceful shutdown started
15:03:52.555 INFO OK Graceful shutdown complete
Then nothing. The next line in the log is 16:22:40 The operation was canceled. — the run was cancelled by hand after 78 minutes. The shell step's
very next statement is an echo, and it never printed, so the shell was still
blocked on that one docker run: the process printed its own graceful-shutdown
line and then did not exit.
Elapsed inside the CLI was 4.3 seconds. Everything after that is a process that
will not die.
Why this is a fix-here defect, not a workflow defect
The composition objectstack#12952 introduced runs host plugins for their
DECLARATIONS: init() runs, start() is replaced with a no-op, destroy() is
forwarded. A host plugin that acquires a resource in init() — a pg pool, an
interval, a socket, a watcher — and releases it from something registered inside
start() now has no release path at all, because the thing that would have
registered the release never ran. The event loop stays alive; process.exit is
never reached; the kernel has already reported a clean shutdown, so nothing looks
wrong.
That is a direct consequence of the declaration-only composition and it is
invisible to the CLI's own test suite, which does not assert that the process
exits. The measured shape — 36 host plugins composed, clean shutdown logged,
process alive 78 minutes later — is the first real deployment this composition
has been run against.
Consequence
os migrate plan and os migrate apply never return on a project with a host
config. For cloud's operator workflow that means the job hangs until the job
timeout (six hours by default) after the plan has already been computed and
printed, and any consumer that reads the --json payload — cloud's coverage gate
is one — never gets to run at all.
Suggested shape
Two candidates, not adjudicated here:
- Make the declaration-only composition symmetric: if
start()is suppressed,
the composition owes the teardown its suppression removed.destroy()is
already forwarded, so the seam exists. - Have
plan/applyexit deliberately once the document is written, rather
than relying on the event loop draining. That is a smaller change and it also
covers host code this repo cannot audit, which is the same argument
objectstack#12952 used for declaration-only composition in the first place.
Filed without an assignee.
Activity
os-zhuang commentedon Aug 28, 2026
Claim (family member): dispatched together with #13028 on branch
claude/issue-13028-composition-seam-family— see the chain-lead claim there for the full serial-constraint and family-gate record. Session:session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6.hotlong commentedon Aug 29, 2026
Family member — implemented on branch
claude/issue-13028-composition-seam-familytogether with #13028. Draft PR: #13057The
os-dev-reportfor this family lives on the chain-lead card: #13028 (comment)This member in one line:
os migrate plan/applynow end the process deliberately once their document is written, after the kernel teardown they already ran, draining stdout and stderr first (a--jsonpayload on a pipe must not truncate) under a bounded wait. Regression pin ispackages/cli/test/migrate-plan-exits.e2e.test.ts, which spawns a real child against a fixture whose host plugin arms a ref'd interval ininit()and would clear it in the suppressedstart(): 2s and exit 0 with the fix, and — ablated, disk-proven — killed at 90053ms without it.lazyPluginwrapper (packages/service-cloud/src/control-plane-preset.ts) definesinit/start/stopand nodestroy, while the kernel's only teardown entry point isplugin.destroy()— nothing inpackages/core/srcever callsplugin.stop(). So roughly twenty control-plane plugins release nothing on shutdown in every cloud boot,os serveincluded. That is the cloud-side root of this card; the framework half landed here makes the command exit regardless of what the host left running.Generated by Claude Code