Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
95db7ba
docs(stack): align runtime architecture documentation
jgoux Aug 5, 2026
b2943a0
fix(process-compose): make unhealthy lifecycle terminal
jgoux Aug 5, 2026
7d19e70
refactor(stack): establish explicit domain model
jgoux Aug 5, 2026
442698f
refactor(process-compose): isolate supervisor policies
jgoux Aug 5, 2026
854f874
Merge remote-tracking branch 'origin/develop' into agent/stack-domain…
jgoux Aug 5, 2026
d6dd4dd
chore(process-compose): merge current develop
jgoux Aug 5, 2026
a596636
chore(process-compose): update supervisor branch base
jgoux Aug 5, 2026
d188f8b
Merge remote-tracking branch 'origin/develop' into agent/stack-domain…
jgoux Aug 5, 2026
8e2ed76
fix(process-compose): handle terminal health failures
jgoux Aug 5, 2026
21b5638
Merge branch 'agent/process-compose-lifecycle-correctness' into agent…
jgoux Aug 5, 2026
57979d5
fix(process-compose): bound supervisor cleanup commands
jgoux Aug 5, 2026
d24cdb9
fix(process-compose): surface recovery hook failures
jgoux Aug 5, 2026
384e0ee
Merge branch 'agent/process-compose-lifecycle-correctness' into agent…
jgoux Aug 5, 2026
6017cc3
test(process-compose): cover recovery hook transition
jgoux Aug 5, 2026
f1ade49
fix(process-compose): fail blocked healthy dependents
jgoux Aug 5, 2026
851479d
Merge branch 'agent/process-compose-lifecycle-correctness' into agent…
jgoux Aug 5, 2026
27e8561
fix(process-compose): hard-kill timed-out cleanup
jgoux Aug 5, 2026
42c775c
fix(process-compose): await restartable dependencies
jgoux Aug 5, 2026
39cc38d
Merge branch 'agent/process-compose-lifecycle-correctness' of github.…
jgoux Aug 5, 2026
48a6057
fix(stack): synchronize status after readiness
jgoux Aug 5, 2026
62f861a
Merge branch 'agent/process-compose-lifecycle-correctness' of github.…
jgoux Aug 5, 2026
c9de546
fix(stack): derive startup coordination timeouts
jgoux Aug 5, 2026
759dd1f
Merge branch 'agent/process-compose-lifecycle-correctness' of github.…
jgoux Aug 5, 2026
a14d408
fix(stack): align startup budget deadlines
jgoux Aug 6, 2026
db7d015
fix(process-compose): await restarting completed dependencies
jgoux Aug 6, 2026
2c825a0
chore(process-compose): integrate lifecycle hardening
jgoux Aug 6, 2026
af6c027
fix(stack): align dependent startup deadlines
jgoux Aug 6, 2026
5a298f1
test(stack): align Docker setup timeout
jgoux Aug 6, 2026
1184ec5
fix(process-compose): terminate timed-out cleanup trees
jgoux Aug 6, 2026
65feb43
Merge remote-tracking branch 'origin/agent/process-compose-lifecycle-…
jgoux Aug 6, 2026
e089d7d
fix(process-compose): preserve active restart policy
jgoux Aug 6, 2026
f275859
fix(stack): serialize projected state snapshots
jgoux Aug 6, 2026
6a737c2
Merge remote-tracking branch 'origin/agent/process-compose-lifecycle-…
jgoux Aug 6, 2026
ed31de0
fix(process-compose): use active policy for readiness
jgoux Aug 6, 2026
267de27
fix(stack): budget native database initialization
jgoux Aug 6, 2026
58c889b
Merge branch 'agent/process-compose-lifecycle-correctness' into agent…
jgoux Aug 6, 2026
eeac2a0
fix(process-compose): handle stopped dependencies
jgoux Aug 6, 2026
1131ac6
Merge branch 'agent/process-compose-lifecycle-correctness' into agent…
jgoux Aug 6, 2026
6b27914
Merge remote-tracking branch 'origin/develop' into agent/stack-domain…
jgoux Aug 6, 2026
4358f44
Merge remote-tracking branch 'origin/agent/stack-domain-model' into a…
jgoux Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions packages/process-compose/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ one of three conditions:

`startService(name)` starts the requested definition and its transitive dependencies.
`stopService(name)` and `restartService(name)` also include active dependents so a caller cannot
leave an already-running dependent attached to a restarted dependency.
leave an already-running dependent attached to a restarted dependency. The pure restart-closure
calculation preserves inactive connector services on a dependency path to an active descendant;
otherwise restarting the descendant without its connector would violate graph order.

`updateServiceDefinition(name, replacement)` validates a graph with the replacement and then swaps
the graph used for subsequent starts and restarts. It does not mutate a process generation that is
Expand Down Expand Up @@ -110,7 +112,8 @@ redundant publications must compare before writing.
`ServiceTransition` is the only normal path for observed-status changes. `applyEvent()` rejects
illegal `(status, event)` pairs by returning `null`; `transition()` applies a legal event atomically
through `SubscriptionRef.modifyEffect`. Races such as a late health callback during shutdown are
therefore ignored without corrupting state.
therefore ignored without corrupting state. The transition classification is keyed by every event
tag, so adding an event fails type checking until its legal source statuses are defined.

## Lifecycle of one process generation

Expand All @@ -121,9 +124,9 @@ For each requested definition, `Orchestrator` runs this sequence:
backoff, allowing a caller to reserve external resources.
3. Wait for dependencies, bounded by `dependencyTimeoutSeconds` (default: 120 seconds).
4. Transition to `Starting`.
5. Call `beforeSpawn` immediately before every spawn. The stack uses it to release a reserved port
as late as possible. For a supervised Docker command there is still a spawn-to-container-bind
window because no supervisor/container bind handshake exists.
5. Call `beforeSpawn` immediately before every spawn. A caller can use it to release a reserved port
as late as possible. For a supervised child there is still a spawn-to-bind window because no
supervisor/child bind handshake exists.
6. Spawn either the configured process or its optional supervisor.
7. Register a scoped finalizer before exposing `Running`.
8. Run `started` hooks sequentially. Only successful hooks allow `ProcessSpawned` to publish
Expand All @@ -137,6 +140,10 @@ For each requested definition, `Orchestrator` runs this sequence:
The service keeps the same state stream across restart generations. Restart backoff is
`min(30 seconds, 2^(restartCount - 1))`.

A no-health-check, `restart: "no"` process is treated as one-shot work. A small isolated poll of
`ChildProcessHandle.isRunning` compensates for adapters that can report process completion before
their `exitCode` Effect becomes observable; it is not part of the general lifecycle loop.

### Lifecycle hooks

Hooks are caller-supplied Effects triggered on `started` or `healthy`. Hooks for one trigger run in
Expand Down Expand Up @@ -212,9 +219,15 @@ In-process cleanup and orphan supervision solve different failure modes:
owner's stdin closes, its PID disappears, the supervisor receives a termination signal, or the
managed child exits while cleanup is configured.

`ExternalCleanupAction` currently supports removing a Docker container or a filesystem path. The
supervisor validates the decoded configuration and ignores individual cleanup failures so cleanup
remains best-effort and idempotent.
`ExternalCleanupAction` supports a shell-free `RunCommand` with an executable, argument array, and
optional timeout (default 5 seconds), plus `RemovePath` for filesystem cleanup. Cleanup commands
receive the managed child's sanitized environment, without the supervisor self-dispatch protocol
variables. The supervisor rejects a malformed decoded cleanup contract before spawning the child,
while individual execution failures remain best-effort. Callers must choose idempotent commands
because owner-loss signals can race. `RunCommand` actions execute serially, so their aggregate
duration can approach the sum of their individual timeouts. Each command runs in its own process
group on Unix, while Windows uses `taskkill /T`; a timeout therefore terminates the command tree
rather than only its root. Filesystem retry delays may overlap command execution.

## Supervisor runtime

Expand Down Expand Up @@ -247,7 +260,8 @@ virtual filesystem. The package therefore uses three internal environment variab
The CLI entrypoint calls `enableSupervisorSelfDispatchForCompiledBun()` before normal command
dispatch, checks `isSupervisorRuntimeRequested()`, and invokes `runSupervisorRuntimeFromEnv()`.
The supervisor removes all three variables before starting the managed command, preventing the
protocol from leaking recursively into a service.
protocol from leaking recursively into a service. Contract tests run the same encoded supervisor
configuration through both the source-file argument path and environment-based self-dispatch path.

The stack daemon has a separate Supabase-owned marker, `SUPABASE_STACK_RUN_DAEMON`; it is documented
in [the stack detach-mode guide](../../stack/docs/detach-mode.md#compiled-executable-re-entry).
Expand All @@ -264,15 +278,13 @@ plus live per-service and merged `PubSub` streams. `historyAll` can filter by se
Streams contain new entries only; callers explicitly request history when they need replay.

When a process exits unexpectedly or becomes unhealthy, the orchestrator appends recent buffered
output to its diagnostics. `truncate` currently clears both a service's history and its entries in
the merged history; it has no production caller.
output to its diagnostics.

## Error model

Graph construction can fail with `MissingDependencyError` or `CyclicDependencyError`. Lifecycle
lookup uses `ServiceNotFoundError`; spawn preparation uses `SpawnError`; readiness uses
`ServiceReadyError`. Global shutdown timeout is logged and force-cleared, so the exported
`ShutdownTimeoutError` does not represent a current failure path.
`ServiceReadyError`. Global shutdown timeout is logged and force-cleared.

## Testing through Interfaces

Expand Down
10 changes: 0 additions & 10 deletions packages/process-compose/src/LogBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export class LogBuffer extends Context.Service<
limit?: number,
services?: ReadonlyArray<string>,
) => Effect.Effect<ReadonlyArray<LogEntry>>;
readonly truncate: (service: string) => Effect.Effect<void>;
}
>()("process-compose/LogBuffer") {
static layer = Layer.effect(
Expand Down Expand Up @@ -97,15 +96,6 @@ export class LogBuffer extends Context.Service<

return filtered.slice(-limit);
}),

truncate: (service) =>
Effect.gen(function* () {
const { buffer } = yield* getOrCreate(service);
yield* Ref.set(buffer, []);
yield* Ref.update(globalBuffer, (entries) =>
entries.filter((entry) => entry.service !== service),
);
}),
};
}),
);
Expand Down
11 changes: 0 additions & 11 deletions packages/process-compose/src/LogBuffer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,6 @@ describe("LogBuffer", () => {
}).pipe(Effect.provide(layer));
});

it.live("truncate clears buffer", () =>
Effect.gen(function* () {
const log = yield* LogBuffer;
yield* log.append("svc", "stdout", "line1");
yield* log.append("svc", "stdout", "line2");
yield* log.truncate("svc");
const entries = yield* log.history("svc");
expect(entries).toHaveLength(0);
}).pipe(Effect.provide(layer)),
);

it.live("subscribeAll receives entries from all services", () =>
Effect.gen(function* () {
const log = yield* LogBuffer;
Expand Down
62 changes: 33 additions & 29 deletions packages/process-compose/src/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts";
import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts";
import { LogBuffer } from "./LogBuffer.ts";
import { restartClosureFor } from "./RestartClosure.ts";
import {
decideRestart,
type LifecycleCause,
Expand Down Expand Up @@ -55,6 +56,8 @@ const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean =>
);
};

// Some one-shot adapters report `isRunning: false` before their exit-code Effect is observable.
// Keep the compensating poll isolated here so the ordinary process-exit path remains event-driven.
const waitForProcessToStop = (handle: {
readonly isRunning: Effect.Effect<boolean, unknown, never>;
}): Effect.Effect<void> =>
Expand Down Expand Up @@ -316,10 +319,10 @@ export class Orchestrator extends Context.Service<

// Release external resources such as port reservations only
// once dependencies are satisfied and spawning is imminent.
// For supervised Docker services, this still leaves a wider
// spawn-to-bind window because the supervisor starts before
// the container binds its published ports. Closing that gap
// would require an explicit supervisor/container handshake.
// For supervised external processes, this can still leave a wider
// spawn-to-bind window because the supervisor starts before its
// child binds published ports. Closing that gap would require an
// explicit supervisor/child handshake.
yield* options?.beforeSpawn?.(def.name) ?? Effect.void;

// Spawn the process
Expand Down Expand Up @@ -632,29 +635,30 @@ export class Orchestrator extends Context.Service<
yield* sendEvent(name, { _tag: "ProcessExited", exitCode: 143 });
});

const restartClosureFor = (name: string): ReadonlyArray<ServiceDef> => {
const names = new Set<string>([name]);
const visited = new Set<string>();
const collectDependents = (current: string): boolean => {
if (visited.has(current)) return names.has(current);
visited.add(current);
let hasActiveDependent = false;
for (const dependent of graph.dependentsOf(current)) {
const dependentService = services.get(dependent.name);
const dependentIsActive =
FiberMap.hasUnsafe(fibers, dependent.name) ||
(dependentService !== undefined &&
SubscriptionRef.getUnsafe(dependentService.state).desired === "running");
const descendantIsActive = collectDependents(dependent.name);
if (dependentIsActive || descendantIsActive) {
names.add(dependent.name);
hasActiveDependent = true;
}
}
return hasActiveDependent;
};
collectDependents(name);
return graph.startOrder.filter((def) => names.has(def.name));
const restartClosure = (name: string): ReadonlyArray<ServiceDef> => {
const activeServices = new Set(
graph.startOrder
.filter((def) => {
const service = services.get(def.name);
return (
FiberMap.hasUnsafe(fibers, def.name) ||
(service !== undefined &&
SubscriptionRef.getUnsafe(service.state).desired === "running")
);
})
.map((def) => def.name),
);
const closure = new Set(
restartClosureFor(
{
order: graph.startOrder.map((def) => def.name),
dependentsOf: (service) => graph.dependentsOf(service).map((def) => def.name),
},
name,
activeServices,
),
);
return graph.startOrder.filter((def) => closure.has(def.name));
};

const waitReadySingle = (def: ServiceDef): Effect.Effect<void, ServiceReadyError> =>
Expand Down Expand Up @@ -861,7 +865,7 @@ export class Orchestrator extends Context.Service<
if (lookupDef(name) === undefined) {
return yield* Effect.fail(new ServiceNotFoundError({ name }));
}
const affected = restartClosureFor(name);
const affected = restartClosure(name);
for (const affectedDef of [...affected].reverse()) {
yield* setDesired(affectedDef.name, "stopped");
yield* sendEvent(affectedDef.name, { _tag: "StopRequested" });
Expand All @@ -876,7 +880,7 @@ export class Orchestrator extends Context.Service<
if (def === undefined) {
return yield* Effect.fail(new ServiceNotFoundError({ name }));
}
const affected = restartClosureFor(name);
const affected = restartClosure(name);

for (const affectedDef of [...affected].reverse()) {
yield* stopForRestart(affectedDef.name);
Expand Down
33 changes: 22 additions & 11 deletions packages/process-compose/src/Orchestrator.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ function mockLogBuffer() {
line: entry.line,
}));
}),
truncate: () => Effect.void,
}),
get entries() {
return entries;
Expand Down Expand Up @@ -455,11 +454,17 @@ describe("Orchestrator", () => {

it.live("supervised services spawn the supervisor runtime", () => {
const { layer, proc } = setupOrchestrator([
svc("postgres", {
command: "docker",
args: ["run", "--rm", "postgres"],
svc("database", {
command: "container-runtime",
args: ["run", "database"],
supervision: {
orphanCleanup: [{ _tag: "DockerRemove", containerName: "supabase-postgres-test" }],
orphanCleanup: [
{
_tag: "RunCommand",
executable: "container-runtime",
args: ["remove", "database-test"],
},
],
},
}),
]);
Expand All @@ -476,22 +481,28 @@ describe("Orchestrator", () => {
it.live("stopping a supervisor during its spawn handshake cleans it up", () => {
const { layer, proc } = setupOrchestrator(
[
svc("postgres", {
command: "docker",
args: ["run", "--rm", "postgres"],
svc("database", {
command: "container-runtime",
args: ["run", "database"],
supervision: {
orphanCleanup: [{ _tag: "DockerRemove", containerName: "supabase-postgres-test" }],
orphanCleanup: [
{
_tag: "RunCommand",
executable: "container-runtime",
args: ["remove", "database-test"],
},
],
},
}),
],
{ exitDelay: "5 seconds" },
);
return Effect.gen(function* () {
const orc = yield* Orchestrator;
yield* orc.startService("postgres", { beforeSpawn: () => Effect.void });
yield* orc.startService("database", { beforeSpawn: () => Effect.void });
yield* proc.waitForSpawnCount(1);

yield* orc.stopService("postgres");
yield* orc.stopService("database");
yield* proc.waitForKillCount(1);

expect(proc.killed[0]?.command).toBe(process.execPath);
Expand Down
37 changes: 37 additions & 0 deletions packages/process-compose/src/RestartClosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface RestartClosureGraph {
readonly order: ReadonlyArray<string>;
readonly dependentsOf: (name: string) => ReadonlyArray<string>;
}

/**
* Returns the requested service and every connector on a path to an active dependent.
* The result preserves dependency start order so callers can stop it in reverse safely.
*/
export const restartClosureFor = (
graph: RestartClosureGraph,
name: string,
activeServices: ReadonlySet<string>,
): ReadonlyArray<string> => {
const closure = new Set<string>([name]);
const visited = new Set<string>();

const collectDependents = (current: string): boolean => {
if (visited.has(current)) {
return closure.has(current);
}
visited.add(current);

let connectsToActiveDependent = false;
for (const dependent of graph.dependentsOf(current)) {
const descendantConnectsToActive = collectDependents(dependent);
if (activeServices.has(dependent) || descendantConnectsToActive) {
closure.add(dependent);
connectsToActiveDependent = true;
}
}
return connectsToActiveDependent;
};

collectDependents(name);
return graph.order.filter((service) => closure.has(service));
};
Loading
Loading