> ## Documentation Index
> Fetch the complete documentation index at: https://growthx-search-attribute-identifier.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# v0.12.0 → v0.13.0

> Upgrading Output.ai projects from v0.12.0 to v0.13.0: worker shutdown defaults, worker exit codes, the catalog startup gate in @outputai/core, and LLM cost during a pricing outage.

This guide covers behavior changes in the `@outputai/core` worker and in `@outputai/llm` cost reporting. Nothing in your code needs to change, but four runtime behaviors differ: the worker now bounds its own shutdown, every way it can end now has a defined outcome and exit code, a worker refuses to start unless it can publish a catalog matching its own source code, and LLM costs fall back to a bundled pricing snapshot instead of coming back empty.

## Worker shutdown is now bounded

`TEMPORAL_SHUTDOWN_GRACE_TIME` and `TEMPORAL_SHUTDOWN_FORCE_TIME` have defaults, and the previously hardcoded hook flush allowance is now `OUTPUT_HOOK_FLUSH_TIMEOUT_MS`:

| Variable | v0.12.0 | v0.13.0 | Meaning |
| - | - | - | - |
| `TEMPORAL_SHUTDOWN_GRACE_TIME` | unset (Temporal default `0`) | `15s` | How long in-flight activities run before they are asked to cancel |
| `TEMPORAL_SHUTDOWN_FORCE_TIME` | unset (no force termination) | `20s` | How long the worker drains before it gives up and terminates |
| `OUTPUT_HOOK_FLUSH_TIMEOUT_MS` | hardcoded `30000` | `5000` | How long pending hook callbacks are awaited once the drain is over |

Previously the worker never force-terminated: it waited indefinitely for in-flight activities and was stopped only when the platform sent `SIGKILL`, which meant the hook flush was never reached at all on a deploy that caught in-flight work. It now stops draining after `20s` and exits under its own control with code `1` - closing its Temporal connection, flushing hooks, and logging first.

For most projects this is an improvement, because activities get 15 seconds of runway instead of being asked to cancel the instant a deploy starts, and buffered hook data actually gets written.

### The three values are one budget

The drain runs to `TEMPORAL_SHUTDOWN_FORCE_TIME` and only then does the flush start, so the worst-case shutdown is force time plus flush timeout. That total has to fit inside the window your platform allows between `SIGTERM` and `SIGKILL`. The defaults total 25 seconds against the 30 seconds most platforms allow, leaving 5 seconds of margin.

The hook flush is the part to watch when retuning. It used to be 30 seconds, which on a default platform window could not complete; it is now 5 seconds, which fits. Five seconds is ample for handlers that write to stdout or an in-process buffer, which is what hook handlers should be doing - they all share this one budget, so a single handler awaiting remote I/O spends the allowance the others need. If you do have a handler that must await a network round trip, batch it and raise both this value and your platform's window, in that order.

### Set the values explicitly if your activities drain slowly

If your activities routinely run longer than 20 seconds and you raised your platform's shutdown window to accommodate them, set the variables explicitly. Otherwise those activities are cancelled at 15 seconds and abandoned at 20 on every deploy, then retried on the new instance.

Keep grace below force, and force plus flush below your platform's shutdown window, so the worker always exits before it is killed:

```bash theme={null}
# Platform allows 300s between SIGTERM and SIGKILL
TEMPORAL_SHUTDOWN_GRACE_TIME=280s
TEMPORAL_SHUTDOWN_FORCE_TIME=285s
OUTPUT_HOOK_FLUSH_TIMEOUT_MS=10000
```

Values above your platform's window never take effect, since the platform terminates the process first. On Render that window is `maxShutdownDelaySeconds`, which defaults to 30 seconds and can be raised to 300. On Kubernetes it is `terminationGracePeriodSeconds`.

Raising these only extends shutdowns started by a signal. When an uncaught exception or unhandled rejection starts the shutdown instead, the worker force quits 60 seconds later regardless of `TEMPORAL_SHUTDOWN_FORCE_TIME`, exiting `1` after logging `Uncaught exception handling timed out, force quitting...`. That cap is fixed, so a force time above 60 seconds only fully applies to signal shutdowns.

An empty value falls back to the default rather than disabling the bound, so there is no longer a way to configure an unbounded drain.

## Shutdown scenarios

Every path that ends the worker changed shape. The exit code is also explicit now: v0.12.0 only called `process.exit` on the failure path and let the event loop empty on the success path.

| Scenario | v0.12.0 | v0.13.0 |
| - | - | - |
| `SIGTERM`, `SIGINT` or `SIGUSR2` while polling | Stops polling and asks in-flight activities to cancel at once, then waits for them for as long as they take. Exits `0` | Stops polling and lets in-flight activities run for `TEMPORAL_SHUTDOWN_GRACE_TIME` before asking them to cancel. Exits `0` |
| In-flight work outlives the drain | Nothing bounds it, so the worker waits until the platform sends `SIGKILL` and the hook flush is never reached. Exit code is whatever the platform reports | Stops draining at `TEMPORAL_SHUTDOWN_FORCE_TIME`, warns with `Stopping Worker error`, flushes hooks. Exits `1` |
| A signal during startup | No handler was installed until the worker had been built, so the process died on the spot with no teardown and no hook flush. Exits `128 + signal number`, `143` for `SIGTERM` | The handler is installed first, so startup stops at the next step, services are torn down and hooks flush. Exits `0` |
| A second signal, more than a second after the first | Force quits on the spot, abandoning the drain. Exits `1` | Unchanged |
| A service other than the worker fails to stop | Warns and carries on with the rest of the teardown. Exit code unaffected | Unchanged |
| Uncaught exception or unhandled rejection | Not handled, so Node printed the stack and terminated immediately: no drain, no hook flush, no `onError` hook. Exits `1` | Drains exactly like a signal, emits `RUNTIME_ERROR` so `onError` hooks fire, flushes hooks. Exits `1` |
| An uncaught error whose drain stalls | Not applicable, there was no drain | Force quits 60 seconds after the error. Exits `1` |
| The Temporal connection is lost | Drains, then reports the loss. Exits `1` | Unchanged |
| The worker fails to stop | Drains what it can, then reports the failure. Exits `1` | Unchanged |
| The catalog cannot be published | The catalog ran alongside the worker, so the worker was already polling against a stale catalog when the failure surfaced. Exits `1` | The catalog gates the worker: it retries twice and never starts polling. Exits `1` |
| Startup fails to load, bundle or connect | Tears down whatever was built. Exits `1` | Unchanged |

If you alert on non-zero worker exits, the situations that produce one changed. A drain that outlives its window is now one of them, where v0.12.0 hung until the platform stepped in, so expect an exit `1` from any deploy that lands on work longer than `TEMPORAL_SHUTDOWN_FORCE_TIME`. Nothing in the logs separates it from another failure to stop: the `Stopping Worker error` warning reports `IllegalStateError: Worker still in use`, because the forced termination leaves in-flight polls behind, and Temporal's own `Worker failed` line serializes its error as empty. Treat a non-zero exit on a deploy you triggered as routine. An uncaught error still exits `1`, but now only after draining.

Two log lines moved with this. The failure log is now `Worker error` rather than `Fatal error`, and the last line is always `Bye`, where a failing worker used to end on `Exiting...`.

## Workers exit when the catalog does not match

The worker publishes its catalog during startup and treats it as a gate. If it cannot publish a catalog matching its own source code, it retries twice and then exits rather than serving requests against an outdated catalog.

A worker that would previously have started with a stale catalog now fails its deploy instead. The failure is logged as `Worker error` with the underlying Temporal error.

Reconciliation also changed: a stale catalog workflow is now terminated rather than asked to complete. A catalog that stopped processing workflow tasks no longer blocks every deploy that follows it.

## LLM cost survives a pricing outage

`@outputai/llm` now ships a snapshot of the pricing table. When the live catalog is unreachable and nothing is cached, costs are calculated from that snapshot instead of coming back as `null`, and are reported with `status: "imprecise"` and `pricingFreshness: "snapshot"`.

If you branch on `cost === null` to skip cost accounting during an outage, that branch stops firing and you start recording snapshot-derived figures instead. Check `pricingFreshness` or `status` for that case. `cost` is still `null` when usage cannot be normalized, and a provider or model missing from the table still produces a non-null, incomplete cost.

## Checklist

* Set `TEMPORAL_SHUTDOWN_GRACE_TIME`, `TEMPORAL_SHUTDOWN_FORCE_TIME` and `OUTPUT_HOOK_FLUSH_TIMEOUT_MS` explicitly if your activities need more than 20 seconds to drain, keeping force time plus flush timeout under your platform's shutdown window.
* Raise `OUTPUT_HOOK_FLUSH_TIMEOUT_MS` if your hook callbacks do real I/O, since the allowance dropped from a hardcoded 30 seconds to 5.
* Update alerts that key off worker exits or the `Fatal error` log line, which is now `Worker error`.
* Expect a worker whose catalog cannot be published to exit instead of starting with a stale catalog.
* Replace any `cost === null` check used to detect a pricing outage with a `pricingFreshness` or `status` check.
