Skip to content

fix(cli): os start forwards SIGTERM/SIGINT to its serve child and reaps it on exit - #21161

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21114-start-signal-forwarding
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21114-start-signal-forwarding

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21114
Clause-②: no

What changed

os start now supervises its serve child through ServeRestartCoordinator, the forwarding mechanism os dev already runs, used as is (packages/cli/src/commands/start.ts):

  • SIGTERM or SIGINT sent to the start process is forwarded to the child (beginShutdown). The child's exit then ends the parent with the child's exit code.
  • Whatever else ends the parent, the child is sent SIGTERM on the way out (killChildOnParentExit on process.on('exit')).
  • A child that exits on its own still ends the parent with code ?? 0, which is the one handler start had before.

Also in this PR: the pin packages/cli/test/start-signal-forwarding.e2e.test.ts and .changeset/21114-start-signal-forwarding.md (@objectstack/cli patch).

Why the coordinator is used as is, and nothing is extracted

Triage asked for one mechanism, "extracted to a shared helper if needed". It is not needed here:

  • start has no restart semantics, so it never calls requestRestart. The other paths are what start needs: start(), beginShutdown, killChildOnParentExit, and the child-exit path (state === 'running' ends the parent with code ?? 0).
  • The class's restart-only messages cannot print from start. They are gated on spawnCount > 1 or are inside requestRestart. The spawn-failure message reads failed to start server, because restartIndex is 0.
  • dev-restart.ts and dev.ts are unchanged, and the coordinator's own unit tests (src/utils/dev-restart.test.ts) already cover beginShutdown and killChildOnParentExit.

The only text the two commands now share is the three-line subscription (process.on for SIGINT, SIGTERM and exit). It stays at each call site, as the dispatch directed for the no-extraction route. What a signal does lives in the coordinator alone.

Measured, before and after

Run on examples/app-showcase with the built entry: node packages/cli/bin/run.js start -p PORT --no-ui & (control: dev -p PORT --no-watch &). The signal went to the parent's pid alone, on a random high port. The before tree is origin/main 7a606a9a34; the after tree is 20eaab626f, where start.ts is byte-identical to this head.

command signal tree parent exit (shell) serve child after port after /api/v1/health after
os start SIGTERM before 143 alive, PPID 1 bound 200
os start SIGINT before 130 alive, PPID 1 bound 200
os start SIGTERM after 0 gone free no answer
os start SIGINT after 0 gone free no answer
os dev (control) SIGTERM before 0 gone free no answer
os dev (control) SIGINT before 0 gone free no answer
os dev (control) SIGTERM after 0 gone free no answer
os dev (control) SIGINT after 0 gone free no answer

On the before tree the orphans had to be killed by their own pid. On the after tree every recorded pid was gone without help.

The Ctrl-C shape was measured on the after tree too (SIGINT to the whole process group). For both start and dev, the parent and the child were gone and the port was free. Both logged one Shutdown already in progress, ignoring SIGINT line, because the child receives the signal from the group and again from the forward. The changeset states this.

The pin

test/start-signal-forwarding.e2e.test.ts boots os start and os dev on the same minimal artifact. For SIGTERM and for SIGINT it signals the parent pid alone, never the group, and asserts that the serve child is gone and the port is free. Both readings have a positive control on the same boot before the signal: exactly one serve child is seen alive, and the port reads bound.

  • Entry. The pin spawns bin/run-dev.js under the tsx loader, passed as --import, so the spawned pid is the CLI parent itself. The built entry would have added a seventh spawner to the six-file population that check:cli-test-child-env pins. The subject is the supervisor wiring in src/commands/start.ts, which both entries run. The built entry is covered by the hand measurement above.
  • Child selection, measured. On a cold tsx transform cache the loader runs an esbuild --service process as a second child of the CLI parent. That process outlives the parent by a moment: it was alive at the parent's exit and gone 2 s later, in 2 of 2 probes with TSX_DISABLE_CACHE=1. One early run of the pin went red on exactly that. The probe now selects the child whose argv carries the serve token, and the pin is green with the cache forced cold.
  • Tier. The .e2e name puts the pin in the nightly tier (OS_TEST_TIERS=nightly), next to start-port-banner-agreement.e2e.test.ts. It does not run in this PR's CI. Each run boots 4 kernels, about 16 s each on a shared box.

Ablation (source mode: the pin reads src/, so no build leg). The fix was committed first. scripts/ablation-replace.mjs replaced the three process.on lines with a planted marker statement and confirmed the change on disk (anchor 1 to 0, blob b6d246505be6 to 1055d17fdbeb). It ran the pin, then restored the file (blob back to b6d246505be6, git diff HEAD empty, porcelain empty). Results at head 4d2d7f9bc6:

  • ablated: os start SIGTERM and SIGINT both red on both readings (left its serve child running, left port N bound); os dev control green on both signals; 2 failed, 2 passed;
  • restored: 4 passed.

An earlier built-entry version of the pin was ablated the same way on dist/, with scripts/ablation-dist-preflight.mjs confirming the marker present and then absent. It gave the same direction on b299389161 and on the merged a096821511.

Verification

All readings below were taken at head 4d2d7f9bc6 unless another commit is named. This branch merged origin/main c6954d6d09 before the last commits, and the build state was refreshed after the merge.

  • Pin, nightly tier, run locally with OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --project integration test/start-signal-forwarding.e2e.test.ts: 4 passed. With the transform cache forced cold (TSX_DISABLE_CACHE=1): 4 passed. Ablated: 2 failed, 2 passed. Restored: 4 passed.
  • Typecheck. pnpm --filter @objectstack/cli typecheck exited 0 (check:test-typecheck: OK).
  • unit tier. pnpm --filter @objectstack/cli exec vitest run --project unit: 242 files and 3432 tests passed at a096821511. Since then only the e2e pin changed, and it is outside the unit project. At 4d2d7f9bc6, test/vitest-tiers-partition.test.ts, src/utils/port-contract-single-source.test.ts and src/utils/dev-restart.test.ts were re-run: 3 files and 51 tests passed. The integration tier is left to CI, because this diff touches no spawn entry and no integration-layer file other than the new nightly pin.
  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 63 commands. Each was run, and every one exited 0. --ran, with an exit code on every line, reported 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET: 8 packages outside the cli closure had no dist/. They were built, and it was re-run as part of the 63. Derivation residual: the tree was 9 commits behind origin/main 70dae533c5, and one derivation input, scripts/doc-authoring-prose-id.baseline.json, changed upstream.
  • Lint, a proven narrowing rather than the full pnpm lint, which is left to CI:
    1. Population: both changed TypeScript files are in eslint's own linted population. --print-config resolves 6 and 5 rules for them, and neither file is ignored.
    2. Count: --format json read 2 files, 0 errors and 0 warnings.
    3. Invariance: the config enables no type-aware linting (parserOptions.project and projectService are undefined for both files). Its only inputs on disk are scripts/slot-lookup-baseline.json and scripts/query-options-erasure-baseline.json, and neither is in this diff, so no verdict on an untouched file can change.

Acceptance notes

  • AGENTS.md written by turbo. The dev-dependency bump at 840ec9dab3 moved turbo from 2.10.10 to 2.11.5. That version appends a managed "turborepo agent rules" block, wrapped in HTML comment markers, to AGENTS.md on repository-scoped commands when it detects an AI agent. turbo.json sets no agentGuidance opt-out. Measured in this worktree: the first pnpm exec turbo run build after merging main left M AGENTS.md (11 added lines). scripts/ablation-dist-preflight.mjs --absent then answered exit 3 on its tree reading. The file was restored from HEAD and is not part of this diff. It is reported to the seat for filing, not fixed here.
  • Port-door anchor. src/utils/port-contract-single-source.test.ts anchors on the text const child = spawn( in start.ts to keep the port door ahead of the spawn. The spawn keeps that spelling inside the coordinator's spawnChild, with a comment saying why, so that structural pin keeps measuring the same order.
  • Coordinator docblock. ServeRestartCoordinator's docblock in dev-restart.ts still describes only os dev. It was left alone because dev-restart.ts is outside this card's surface on the no-extraction route. start.ts names the coordinator and the pin instead.
  • Behaviour on signal. After SIGTERM or SIGINT, os start now waits for the server's graceful shutdown and exits 0. Before, it died on the signal (shell status 143 or 130) while the server kept running. This matches os dev.

Generated by Claude Code

claude added 7 commits October 1, 2026 09:12
…ps it on exit

`os start` listened for its `serve` child's exit and nothing else, so a
signal to the start pid alone orphaned the child with its port bound and
/health answering. Supervise the child through ServeRestartCoordinator,
the forwarding mechanism `os dev` already runs, used as is: beginShutdown
on SIGINT/SIGTERM, killChildOnParentExit on exit, and a self-exiting child
still ends the parent with `code ?? 0`.

Pinned end to end (nightly e2e tier) with `os dev` as the control: a
SIGTERM or SIGINT to the parent leaves no child process and a free port.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
The built entry (`bin/run.js`) puts a spawner into the population
`check:cli-test-child-env` pins at six files. The pin's subject is the
supervisor wiring in src/commands/start.ts, which both entries run, so it
spawns `bin/run-dev.js` under the tsx loader as an `--import` flag: the
spawned pid is the CLI parent itself and the `serve` process its direct
child. The bound port is still read back from the child's banner.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
On a cold tsx transform cache the loader's `esbuild --service` process is
a second child of the CLI parent and exits a moment after it. Counting
every child read that as an orphaned server on the file's first boot.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 1 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2488b98b48f51e2a1bc5b5e50fc1c206c9565291.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0f744c6b8b60d12e01ebbc05f7a432e3db25de61 — the merge of head 4d2d7f9bc68c75165f7e1f15af22c32ac3f1f8fd into base 2488b98b48f51e2a1bc5b5e50fc1c206c9565291, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0f744c6b8b60d12e01ebbc05f7a432e3db25de61 && git checkout 0f744c6b8b60d12e01ebbc05f7a432e3db25de61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 4d2d7f9bc68c75165f7e1f15af22c32ac3f1f8fd && git checkout -B drift-repro 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 && git merge --no-ff 4d2d7f9bc68c75165f7e1f15af22c32ac3f1f8fd

node scripts/docs-audit/affected-docs.mjs --json 2488b98b48f51e2a1bc5b5e50fc1c206c9565291

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4d2d7f9bc68c75165f7e1f15af22c32ac3f1f8fd
Local-runs: none

Reviewed against: card #21114 (body; triage grade 5927238566; claim 5927858321; os-dev-report 5929997390), PR #21161 (body, 3-file list, net diff git diff c6954d6d09..refs/review/pr-21161: +362 / −12), packages/cli/src/utils/dev-restart.ts and packages/cli/src/commands/dev.ts at the head (read, unchanged), the neighbour pin start-port-banner-agreement.e2e.test.ts, the tier partition (scripts/nightly-tiers.mjs, packages/cli/vitest-tiers.ts, packages/cli/vitest.config.ts, ci.yml, test-nightly-tiers.yml), scripts/check-cli-test-child-env.mjs, src/utils/dev-restart.test.ts, src/utils/port-contract-single-source.test.ts, bin/run-dev.js, test/helpers/serve-process.ts, the head's check-runs polled to convergence, and a driverless merge probe against freshly fetched origin/main.

① Derived judgments

(a) One mechanism, no second copy — holds.

  • start.ts (:447–:492 at the head) imports ServeRestartCoordinator from ../utils/dev-restart.js, constructs it with spawnChild (the base's spawn, moved verbatim) and exitParent: (code) => process.exit(code), subscribes SIGINT / SIGTERM to beginShutdown and exit to killChildOnParentExit, then coordinator.start(). That is the wiring dev.ts:694–:712 has, minus dev's chalk log sink. Nothing was extracted and nothing needed to be: the coordinator already lives in packages/cli/src/utils/ and is the one forwarding mechanism in the CLI.
  • No restart-only path can fire from start. The four coordinator calls at start.ts:469–:492 are the only ones in the file; requestRestart is never called, so the stopping state, the ↻ restarting lines and armForceKill are unreachable. The ✗ server exited … stopping dev line in onChildExit is gated on spawnCount above 1, and spawnCount reaches 1 at start() and only increments on the respawn that requestRestart alone enters. The spawn-failure line reads failed to start server because restartIndex is 0. The default log sink is console.log, which redirectStdoutToStderr() at start.ts:155 routes to stderr; it is reachable only on a synchronous spawn throw (before: an uncaught exception, exit 1; after: one line, exit 1).
  • The three-line process.on subscription at each call site is wiring (which process event invokes which coordinator method), not a copy of the signal logic: what a signal DOES (forward, wait, end the parent with the child's code; SIGTERM the child on any parent exit) exists once, in the coordinator, and a change there reaches dev and start together. Folding the three lines into a helper would be a three-line function and an edit to dev.ts outside the no-extraction route the claim set. Judged wiring, within the grade.
  • A child that exits on its own still ends the parent with code ?? 0: onChildExit in state running calls exitParent(code ?? 0), byte-equivalent to the removed child.on('exit', (code) => process.exit(code ?? 0)), and pinned per PR in dev-restart.test.ts (including the signal-killed null → 0 case).
  • Exit codes. After SIGTERM / SIGINT the parent now waits for the child's graceful exit and exits with the child's code (0), where it previously died on the signal (shell status 143 / 130) and orphaned the child. os dev does exactly this (same beginShutdown path; the PR's repro table shows the dev control at parent exit 0 before and after). It is a behaviour change and it is stated in both places: the changeset's first bullet names both old statuses, and the PR body's Acceptance notes carry a "Behaviour on signal" entry. No page at the head promises 143 / 130 for os start (grep over content/docs, the READMEs and docs/*.md for SIGTERM / SIGINT / 143 / 130 finds only the kernel lifecycle page's graceful-shutdown description and an unrelated tenancy table). A caller that read the status to tell "stopped by signal" from "clean exit" now reads 0, the reading os dev has always given. One consequence to name: beginShutdown arms no SIGKILL escalation (only requestRestart does) and absorbs a second signal (state === 'shutdown' early return), so a wedged child holds start until the supervisor's own escalation (docker stop's 10 s, systemd's TimeoutStopSec). Identical to os dev, inherent to the ruled mechanism, and a divergence here would be the second copy the grade forbids. Noted, no action.

(b) Every other start behaviour unchanged — holds.

  • The diff's one code hunk is start.ts:445–:492. localEnv (:400–:446: childEnvWithResolvedArtifact, OS_HOME, OS_ENVIRONMENT_ID, OS_DATABASE_URL, childPortEnv(flags.port) writing OS_PORT + PORT on the [finding] os start --port forwards the flag on the LOWER-priority channel, so $OS_PORT silently wins — and the banner prints the flag's value while the server binds the other one #12992 env channel, the NODE_ENV production default at :435, OS_CRYPTO_AUTOKEY at :444–:446) is untouched context, as is the port door at :343–:352, which stays ahead of the const child = spawn( anchor; port-contract-single-source.test.ts ("puts each parent's refusal AHEAD of its spawn", unit tier, per PR) keeps measuring that order.
  • The spawn's three arguments moved verbatim into the closure: process.execPath; [binPath, 'serve', flags.ui ? '--ui' : '--no-ui', ...verbose, ...log-level]; { stdio: 'inherit', env: localEnv }. No ipc fd, no --dev, no --port on argv, no message listener: those live in dev.ts's own spawnServeChild (:624–:691), not in the coordinator.
  • What the coordinator adds around the spawn: an info.restartIndex argument (ignored by start's closure), a try/catch (effect only on a synchronous throw), child.once('exit') in place of on('exit') (one event either way), and state bookkeeping. No env, no flags, no output on the normal path. Neither tree subscribes an error listener (unchanged).

(c) The pin — sound.

  • test/start-signal-forwarding.e2e.test.ts boots os start and os dev on one minimal artifact in an empty temp cwd via process.execPath --import tsx/dist/loader.mjs bin/run-dev.js … (so the spawned pid IS the CLI parent; the tsx CLI would interpose its own pid), detached: true, env through childEnv() with NO_COLOR / OS_HOME / OS_LOG_LEVEL overrides. It waits for the banner tail, reads the bound port from the child's own banner (boundPortFromBanner), takes positive controls on the same boot (exactly one direct child whose argv carries serve, alive; the port reads bound), sends process.kill(parentPid, signal) to the parent alone (never the group), waits for the parent's exit (60 s), then takes two soft readings: no captured child pid alive, port free. 2 signals × 2 commands; os dev is the control.
  • Reds without the fix by construction: with the subscriptions gone the parent dies on the signal (Node default), the child survives reparented, both readings go red, and the parent's exit-by-signal still settles the exited promise so the failure is an assertion rather than a timeout; the dev cases are untouched by the ablation and stay green. The dev's recorded ablation (three process.on lines replaced by a marker on the committed fix, restore proved by blob hash b6d246505b) gave exactly that shape: 2 failed / 2 passed; restored: 4 passed; cold transform cache (TSX_DISABLE_CACHE=1): 4 passed. Consistent with the code as read; not re-run here.
  • Child probe: ps -A -ww -o pid=,ppid=,args= filtered on ppid === parentPid and a whitespace-split token equal to serve. The tsx loader's esbuild --service straggler carries --service=… and --ping, never the bare token; the parent's own argv carries start / dev and is excluded by ppid regardless. Pids are captured BEFORE the signal and re-probed after, so pid reuse cannot pass an orphan off as dead; isAlive treats EPERM as alive. The toHaveLength(1) control turns any topology surprise into a loud red, never a vacuous green (e.g. bin/run-dev.js's TSX_TSCONFIG_PATH re-exec fires only when the CWD's tsconfig redirects an @objectstack/* dependency to source; the pin's cwd is an empty temp dir, so it cannot fire).
  • Reaping: afterEach SIGKILLs the whole process group (negative pid; the parent is spawned detached so the group is its own, and the serve child plus any esbuild service inherit it), then removes the workdir; running is set synchronously before any await, so the boot-timeout, banner-unreadable and ablated-orphan paths are all covered. reservePort's node -e probe and execFileSync('ps') are synchronous and self-terminating. Every process it starts is reaped.
  • Source entry versus check:cli-test-child-env: rule 3 anchors on a string literal ending bin/run.js inside a spawn call; bin/run-dev.js does not match (the script says so in its own note), so the pin adds no built-entry spawner, no DELIBERATE_REROUTE entry and no NODE_ENV scrub; rules 1–2 are met (childEnv() at both spawn sites, no bare process.env). Lint & Repo Gates, which runs that gate, is green on the head. The built entry is covered by the hand measurement in the PR body (both signals, before and after), and the subject — the wiring in start.ts — is the same source under either entry. Acceptable; the trade (no dist prerequisite, no gate edit outside the claimed surface) is the right one. The source entry pins NODE_ENV=development into the child (auto-shift open), harmless because the port is read back from the banner.

(d) The open question — nightly is acceptable; no per-PR guard is owed for PASS.

  • The partition: scripts/nightly-tiers.mjs is the one reader of OS_TEST_TIERS; unset / queue excludes *.e2e.test.* and *.live.test.*, nightly runs exactly them; ci.yml's Test Core sets OS_TEST_TIERS: queue (:683–:690) and the merge queue runs the same workflow; test-nightly-tiers.yml runs the tiers nightly on main and on red files or refreshes one card titled nightly-tiers: red on main (bug · domain:devx · priority:p1) from vitest's JSON report. Both files carry the maintainer direction of 2026-09-07 verbatim: 「我想的是测试会不会太多,是否都是必要的,是不是应该砍,每次修改都要完整的测试吗」, with the ruling that a file's run is decided by the tier its name already carries.
  • Per-PR coverage that exists: src/utils/dev-restart.test.ts (unit tier, queue) pins beginShutdown (forward, child exit ends the parent, no respawn; shutdown during a restart wins), killChildOnParentExit (SIGTERM a live child, no-op after exit) and the self-exit code ?? 0 contract; port-contract-single-source.test.ts pins the spawn anchor's position. Nothing per PR pins the three process.on lines in start.ts: a PR deleting them would be green per PR and red on the next nightly, filed p1 within a day.
  • Judgment: the e2e file is the behaviour pin the grade asked for; the standing direction assigns a test that boots four real kernels (about a minute per cli-affected PR) to the nightly; the neighbour the claim named sits in the same tier. Option A is right. If the seat wants a per-PR guard anyway, the minimal one inside the claim's surface is one it in packages/cli/src/utils/dev-restart.test.ts (a named pin location) that reads src/commands/start.ts and asserts the four wiring lines are present (new ServeRestartCoordinator(, SIGINT and SIGTERM to beginShutdown, exit to killChildOnParentExit), in the structural-anchor shape port-contract-single-source.test.ts already uses. It pins text, not behaviour, so it complements the nightly pin; it is not a condition of this verdict. Option B (dropping .e2e) is permitted by the partition but moves real boots back into every cli PR against the direction's grain; not recommended.

② Semver level

.changeset/21114-start-signal-forwarding.md: '@objectstack/cli': patch, Clause-②: no. Correct. A bug fix in a released package takes patch; the diff adds no flag, export, environment variable, accepted value or authorable key (one import, one constructor call, three subscriptions; dev-restart.ts's exports are unchanged). Clause ② concerns the authorable and accept surface; a CLI process's status after SIGTERM is not one, and os dev's has been 0 all along. The exit-status change is a behaviour change and owes a statement, which the changeset body gives (both old statuses named, the wait-for-child described, the Ctrl-C double-SIGINT side effect declared); it does not owe minor, a migration or an ADR-0087 disposition. Check Changeset is green.

③ Boundary flags

  • Deviations, each read: source entry — judged in ①(c), acceptable. Merge of origin/main c6954d6d09 — AGENTS §10; merge commit a096821511 carries the trailer pair; the net diff is computed against that base and is 3 files. Amend before the first push — the 7 remote commits are linear and none carries a closing keyword, consistent with check-commit-card-trailers.mjs's pre-push refusal; no shared branch was rewritten. Gate runner reverting an edit once — local only; the head was re-gated after the final commit (63 derived / 63 run / 0 unrun). setsid probe's four unrecorded processes — observed by worktree path, never killed by name, exited on their own; declared. Trailer pair — every commit ends with the model-free Claude-Session: and Co-authored-by: pair AGENTS requires, the PR body carries the session-URL footer, and the harness reminder was correctly overridden. Eight packages built for check:dual-build-cjs-loads — local, no tree change. Child-probe selection — judged sound in ①(c).
  • Out-of-scope finding (1), turbo: real at the head. @turbo/types@2.11.5's schema.v2.json (read from the npm registry) documents agentGuidance: "Controls whether turbo maintains a root AGENTS.md block for AI agents. Defaults to true. Set to false to opt out"; the root package.json pins turbo ^2.11.5 (lockfile 2.11.5, bumped by 840ec9dab3, chore(deps)(deps-dev): bump the development-dependencies group across 1 directory with 16 updates #21024); turbo.json at the PR head and at origin/main ae1e95010a carries no opt-out; AGENTS.md on main carries no such block. Class: a tooling trap that dirties a Tier H governed surface under any agent seat's ordinary turbo-backed command, reach repo-wide (a git add -A after it turns that PR Tier H; also a false ablation-dist-preflight --absent reading). Already closed on main: fde553c509 "chore(turbo): opt out of the agent-guidance block in the root turbo.json (chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151)" sets "agentGuidance": false. No card owed; this PR correctly leaves turbo.json alone.
  • Out-of-scope finding (2), docblock: real and cosmetic — ServeRestartCoordinator's docblock still says "the single serve child of objectstack dev". dev-restart.ts is outside the surface on the no-extraction route, and start.ts's comment names the coordinator and the pin. Rides a later touch of dev-restart.ts; no action.
  • Out-of-scope finding (3), double SIGINT: real and inherent — a terminal Ctrl-C signals the group and the forward delivers a second SIGINT; packages/core/src/kernel.ts:908 logs one Shutdown already in progress, ignoring SIGINT warn. Identical for os dev today and stated in the changeset. No action.
  • Fixes #21114: first line of the PR body; the card's closing-PR link names fix(cli): os start forwards SIGTERM/SIGINT to its serve child and reaps it on exit #21161; The card this PR closes must claim this branch and Part-of PR must not also close its card are green; the card is the defect this PR repairs. Closes correctly.
  • dev-restart.ts and dev.ts untouched: the file list is .changeset/21114-start-signal-forwarding.md, packages/cli/src/commands/start.ts, packages/cli/test/start-signal-forwarding.e2e.test.ts; git diff --stat c6954d6d09..head is those 3 files, +362 / −12; none of the five files the claim named moved on main between the base and fde553c509.
  • Checks on the head: 34 check-runs on 4d2d7f9bc6, collapsed latest-per-name, polled to convergence: 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 in progress, 0 failure. All seven required contexts are green (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). No check is red on the head, so there is no red to compare against origin/main.
  • Mergeability: git merge-tree --write-tree --name-only run from a driverless bare probe sharing the object store (PROBE.git, no merge.os-regen.driver registered) of freshly fetched origin/main fde553c509 (13 commits past the base) with the head: clean, tree ec54d9b4c4, no conflicted paths; none of the PR's paths is merge=os-regen. The earlier probe against ae1e95010a was clean as well.
  • Governed surfaces: none touched. Size 3 files / 374 changed lines. The PR is a draft and is not armed.

Implemented-by: claude/issue-21114-start-signal-forwarding
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 11:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 11:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 7164587 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21114-start-signal-forwarding branch October 1, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants