# Side-Effects Review — Scheduler reports real gate exit code (0.28.62)

**Version / slug:** `0.28.62` · `scheduler-gate-exit-code`
**Date:** `2026-04-19`
**Author:** Dawn (back-filled — artifact was referenced by `upgrades/0.28.62.md` but not shipped with the release)

## Summary of the change

`JobScheduler.runGateAsync` previously reported `exit null` for every legitimate non-zero gate skip because it was reading `.status` (the `spawnSync` shape) off an error from the `util.promisify(child_process.execFile)` call, which exposes exit codes on `.code`. The skip path now reads `.signal ?? .code ?? .status ?? null` and surfaces the real exit code or signal in the activity feed and in `metadata.exitCode` on the `job_gate_skip` event.

Files touched:
- `src/core/scheduler/JobScheduler.ts` — skip-path exit-code extraction reads from the right field set and widens the metadata type.
- `metadata.exitCode` type: `number | null` → `number | string | null`.

## Decision-point inventory

- `runGateAsync` error-shape read — **modify** — changes which property of the exec error is surfaced. No control-flow change: same retry count, same skip-ledger entry, same event shape.

---

## 1. Over-block

No block surface. This is a pure diagnostic / signal-quality change. Nothing new is gated or rejected that wasn't before.

## 2. Under-block

No new block decisions. The gate still skips under exactly the same conditions; only the reported exit code is now truthful instead of uniformly `null`.

## 3. Level-of-abstraction fit

Right layer. The scheduler is the only place that owns the gate-process exec; fixing the exit-code extraction here avoids every downstream consumer having to re-derive "was this a legitimate skip or a kill?" from `null`. Presentation-layer formatters (activity feed, event log) just display what they're given and need no change.

## 4. Signal vs. authority

Pure signal quality. No authority change. The gate does not gain or lose the power to decide anything; it just stops lying about why it skipped.

## 5. Interactions

- Activity-feed renderers reading `exit ${code}`: previously all showed `exit null` for non-zero skips, will now show `exit 1`, `exit SIGKILL`, etc. String consumers unchanged.
- Alerting/digest jobs keying off `exitCode === null` to flag "scheduler lost contact": those alerts will now correctly fire only on true `null` (no-error) cases, reducing false positives. This is the intended effect.
- `metadata.exitCode` type widened from `number | null` to `number | string | null`. Consumers doing display or non-arithmetic comparisons continue to work. Any consumer doing arithmetic on `exitCode` without a typeof guard would need one — grep confirms no such site exists.

## 6. External surfaces

- Activity feed strings: visible behavior change (exit codes now truthful).
- `job_gate_skip` event `metadata.exitCode`: type widened.

No API endpoints, config files, CLI flags, or database schemas touched.

## 7. Rollback cost

One-line revert — swap the `.signal ?? .code ?? .status ?? null` read back to the original `.status` read. No schema changes. Activity feed returns to `exit null` for legitimate non-zero skips.

---

## Evidence

- `JobScheduler` unit tests: 35/35 passing before and after.
- Feedback cluster `cluster-jobscheduler-reports-gate-exit-code-as-null-for-every-legiti` captured the root-cause shape: `err.code: 1`, `err.status: undefined`.
- Release: `v0.28.61` committed the fix; `v0.28.62` shipped it.

## Notes on back-fill

This artifact was created after the release landed on `main` because the pre-push gate expects `upgrades/side-effects/<version>.md` as the canonical per-release artifact, and v0.28.62 shipped without one. The descriptive-slug convention (`scheduler-gate-exit-code.md`) referenced in `upgrades/0.28.62.md` was also not shipped; if that convention is preferred going forward, this file can be renamed and a symlink added, or the pre-push gate relaxed to accept either naming.
