| β¦ | β¦ | @@ -0,0 +1,659 @@ | |
| 366 | + | this.#rejectCompletion(error); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | async stop(signal: NodeJS.Signals): Promise<void> { | |
π High src/serviceLauncher.ts:369
stop() sends the requested signal to the child and then waits indefinitely for it to exit via waitForExit, with no grace timeout or SIGKILL fallback. A child that handles or ignores SIGTERM/SIGINT blocks #completion from resolving, so the launcher hangs during shutdown until an external timeout kills the process tree. terminateChild() already implements a grace period followed by SIGKILL β stop() should use the same strategy so shutdown always completes.
β Resolved in 32ae738ae53a35a89fb8caf9ad9982a95512b3da
[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO
Fixed in 32ae738ae. stop() now runs through the serialized transition queue and uses the shared terminate helper, which preserves the requested signal and escalates to SIGKILL after the grace period.
d408091Open thread| β¦ | β¦ | @@ -1802,14 +1803,16 @@ const make = Effect.gen(function* () { | |
| 1806 | + | const start: ProviderRuntimeIngestionShape["start"] = (activation) => | |
| 1806 | 1807 | Effect.gen(function* () { | |
| 1807 | - | yield* Effect.forkScoped( | |
| 1808 | + | yield* forkParked( | |
π High Layers/ProviderRuntimeIngestion.ts:1808
forkParked delays Stream.runForEach(providerService.streamEvents, ...) until after activation completes, so the ingestion subscriber is not installed while the server waits at the activation boundary. ProviderService.streamEvents is a fresh PubSub subscription that does not replay missed events, so any provider events published during the prepared-to-activated interval are permanently dropped β lifecycle and message projections miss those events after handoff. The stream subscription needs to start before parking so events are queued, with processing deferred until activation completes.
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around line 1808: |
| 2 | |
| 3 | `forkParked` delays `Stream.runForEach(providerService.streamEvents, ...)` until after `activation` completes, so the ingestion subscriber is not installed while the server waits at the activation boundary. `ProviderService.streamEvents` is a fresh `PubSub` subscription that does not replay missed events, so any provider events published during the prepared-to-activated interval are permanently dropped β lifecycle and message projections miss those events after handoff. The stream subscription needs to start before parking so events are queued, with processing deferred until activation completes. |
[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO
Verified as not reachable in this lifecycle. A fresh candidate has no active provider session or provider process that can publish runtime events: all HTTP and WebSocket ingress now waits on command readiness, orchestration roots and the session reaper are parked, and ProviderService only starts or recovers an adapter session in response to those commands. There is therefore no producer during the prepared-to-activated interval. Starting subscriptions early would add buffering machinery without an event source.
d408091Open thread| β¦ | β¦ | @@ -1350,6 +1352,7 @@ const make = Effect.gen(function* () { | |
| 1350 | 1352 | ); | |
| 1351 | 1353 | }), | |
| 1352 | 1354 | ); | |
| 1355 | + | yield* activation === undefined ? clearInterrupted : forkParked(activation, clearInterrupted); | |
π High Layers/ProviderCommandReactor.ts:1355
clearInterruptedThreadTitleRegenerations now runs behind activation via forkParked, so it reads the projection snapshot only after activation β concurrently with the newly-started domain-event stream. A thread.meta-updated regeneration request that arrives right after activation is picked up by this cleanup pass and completed with no title as if it were stale/interrupted, racing the worker that is actively processing the same request and silently discarding the generated title. The stale-request scan should capture request IDs before activation and defer only the correlated completions, or otherwise run cleanup before the stream starts accepting new events.
β Resolved in 32ae738ae53a35a89fb8caf9ad9982a95512b3da
[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO
Fixed in 32ae738ae. The reactor now snapshots the exact (threadId, requestId) pairs before installing the stream or waiting for activation, then dispatches only those correlated completions after activation.
| β¦ | β¦ | @@ -72,105 +68,137 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu | |
| 195 | + | yield* input.validate(paths); | |
| 196 | + | } | |
| 197 | + | return paths; | |
| 198 | + | }).pipe( | |
π‘ Medium cloud/pinnedRuntime.ts:198
Effect.tapError only runs its handler on typed failures, not on interruption. If the install fiber is interrupted during npm install, validation, sentinel writing, or rename, the .staging-* temp directory is left behind. Repeated interrupted installs accumulate full node_modules trees under runtime/versions, consuming disk space. Consider using Effect.ensuring so cleanup runs on both failure and interruption.
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/cloud/pinnedRuntime.ts around line 198: |
| 2 | |
| 3 | `Effect.tapError` only runs its handler on typed failures, not on interruption. If the install fiber is interrupted during `npm install`, validation, sentinel writing, or rename, the `.staging-*` temp directory is left behind. Repeated interrupted installs accumulate full `node_modules` trees under `runtime/versions`, consuming disk space. Consider using `Effect.ensuring` so cleanup runs on both failure and interruption. |
| β¦ | β¦ | @@ -0,0 +1,659 @@ | |
| 364 | + | if (child?.connected) child.disconnect(); | |
| 365 | + | child?.unref(); | |
| 366 | + | this.#rejectCompletion(error); | |
| 367 | + | } | |
π High src/serviceLauncher.ts:363
#fatal() calls disconnect() and unref() on the managed child but never terminates it. Because disconnect() only closes the IPC channel and unref() only lets the parent exit independently, the child process keeps running. Any transition failure while a child is alive (e.g., a durable state write or IPC send failure) rejects the launcher's completion promise while leaving the server process orphaned and still serving, which can conflict with a restarted launcher's new child. Terminate the child before rejecting completion.
| 1 | - const child = this.#child?.process; |
| 2 | - this.#child = null; |
| 3 | - if (child?.connected) child.disconnect(); |
| 4 | - child?.unref(); |
| 5 | - this.#rejectCompletion(error); |
| 6 | + const child = this.#child?.process; |
| 7 | + this.#child = null; |
| 8 | + if (child !== undefined) { |
| 9 | + if (child.connected) child.disconnect(); |
| 10 | + child.unref(); |
| 11 | + await terminateChild(child); |
| 12 | + } |
| 13 | + this.#rejectCompletion(error); |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/serviceLauncher.ts around lines 363-367: |
| 2 | |
| 3 | `#fatal()` calls `disconnect()` and `unref()` on the managed child but never terminates it. Because `disconnect()` only closes the IPC channel and `unref()` only lets the parent exit independently, the child process keeps running. Any transition failure while a child is alive (e.g., a durable state write or IPC send failure) rejects the launcher's completion promise while leaving the server process orphaned and still serving, which can conflict with a restarted launcher's new child. Terminate the child before rejecting completion. |
| β¦ | β¦ | @@ -72,105 +68,137 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu | |
| 85 | + | ); | |
| 86 | + | const alreadyPinned = | |
| 87 | + | entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; | |
| 88 | + | if (versionDirExists && !alreadyPinned) { | |
π High cloud/pinnedRuntime.ts:88
An interrupted install now leaves the pinned-runtime directory in a state that permanently blocks all future installs of that version. When versionDir exists but the sentinel or entry file is missing (the exact partial state left by a killed npm install), the function returns PinnedRuntimeInstallError with step "checking an incomplete pinned runtime" instead of cleaning up and retrying. Every subsequent ensurePinnedRuntimeInstalled call for that version hits the same check and fails identically, so the runtime can never repair itself without manual filesystem cleanup.
The previous implementation removed and rebuilt the incomplete directory; this change replaced that with an early failure. Consider either removing the incomplete versionDir and proceeding with a fresh install, or keeping the cleanup-and-retry path.
Separately, when validate fails on an already-pinned runtime (sentinel and entry exist but the runtime is corrupt), the function fails without removing the bad directory. Since the prior removePinnedRuntimeInstallation cleanup was removed in this PR, every retry re-validates the same corrupt runtime and fails again. Consider removing or rebuilding the runtime when validation fails so retries can recover.
Also found in 1 other location(s)
apps/server/src/cloud/selfUpdate.ts:92
ensurePinnedRuntimeInstalledrevalidates an already-published runtime but leaves it in place whenvalidatefails. This change removes the previousremovePinnedRuntimeInstallationcleanup, so a runtime with a valid sentinel/entry that is corrupt or fails preflight is reused on every subsequent update attempt; retries can never reinstall that version and always fail until the directory is manually deleted.
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/cloud/pinnedRuntime.ts around line 88: |
| 2 | |
| 3 | An interrupted install now leaves the pinned-runtime directory in a state that permanently blocks all future installs of that version. When `versionDir` exists but the sentinel or entry file is missing (the exact partial state left by a killed `npm install`), the function returns `PinnedRuntimeInstallError` with step `"checking an incomplete pinned runtime"` instead of cleaning up and retrying. Every subsequent `ensurePinnedRuntimeInstalled` call for that version hits the same check and fails identically, so the runtime can never repair itself without manual filesystem cleanup. |
| 4 | |
| 5 | The previous implementation removed and rebuilt the incomplete directory; this change replaced that with an early failure. Consider either removing the incomplete `versionDir` and proceeding with a fresh install, or keeping the cleanup-and-retry path. |
| 6 | |
| 7 | Separately, when `validate` fails on an already-pinned runtime (sentinel and entry exist but the runtime is corrupt), the function fails without removing the bad directory. Since the prior `removePinnedRuntimeInstallation` cleanup was removed in this PR, every retry re-validates the same corrupt runtime and fails again. Consider removing or rebuilding the runtime when validation fails so retries can recover. |
| 8 | |
| 9 | Also found in 1 other location(s): |
| 10 | - apps/server/src/cloud/selfUpdate.ts:92 -- `ensurePinnedRuntimeInstalled` revalidates an already-published runtime but leaves it in place when `validate` fails. This change removes the previous `removePinnedRuntimeInstallation` cleanup, so a runtime with a valid sentinel/entry that is corrupt or fails preflight is reused on every subsequent update attempt; retries can never reinstall that version and always fail until the directory is manually deleted. |
[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO
Fixed in 32ae738ae. Incomplete final directories are removed and rebuilt. A completed runtime that fails install validation is also removed and reinstalled, while a distinct tagged preflight-blocked error preserves valid runtimes that are merely incompatible with the current migration frontier.
| β¦ | β¦ | @@ -0,0 +1,659 @@ | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise<void> { | |
| 404 | + | if (!(await runtimeExists(this.#baseDir, version))) { | |
π High src/serviceLauncher.ts:403
stop() runs outside the #transitions queue, so shutdown races with in-progress startup or rollback transitions. If a signal arrives while #recover() or #startChild() is awaiting runtimeExists() or spawn, stop() sees #child === null, resolves completion, and then the transition installs a new child afterward β that child never receives the shutdown signal and is left running. The same race affects #rollback: after it writes state but before #startChild spawns, stop() can clear the current child and resolve, after which the transition still spawns a new child because #startChild does not check #stopping. Serialize stop() through #transitions or re-check #stopping inside #startChild before publishing the spawned child.
| 1 | async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise<void> { |
| 2 | + if (this.#stopping || this.#finished) return; |
| 3 | if (!(await runtimeExists(this.#baseDir, version))) { |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/serviceLauncher.ts around lines 403-404: |
| 2 | |
| 3 | `stop()` runs outside the `#transitions` queue, so shutdown races with in-progress startup or rollback transitions. If a signal arrives while `#recover()` or `#startChild()` is awaiting `runtimeExists()` or `spawn`, `stop()` sees `#child === null`, resolves completion, and then the transition installs a new child afterward β that child never receives the shutdown signal and is left running. The same race affects `#rollback`: after it writes state but before `#startChild` spawns, `stop()` can clear the current child and resolve, after which the transition still spawns a new child because `#startChild` does not check `#stopping`. Serialize `stop()` through `#transitions` or re-check `#stopping` inside `#startChild` before publishing the spawned child. |