Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/capture-actor-service-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@humanlayer/effect-machine": patch
---

Preserve Effect service dependencies supplied while allocating a local actor, so state effects run correctly when `actor.start` is called later.
10 changes: 10 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ pre-commit:
run: bun run typecheck
- name: test
run: bun run test

pre-push:
parallel: true
jobs:
- name: fmt
run: bun run fmt:check
- name: lint
run: bun run lint
- name: typecheck
run: bun run typecheck
13 changes: 11 additions & 2 deletions src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
},
) {
const lifecycle: Lifecycle<S, E> | undefined = options?.lifecycle;
const serviceContext = yield* Effect.context<R>();

// Spawn is cold — initial state from hydrate or machine.initial.
// Recovery runs during start, not allocate.
Expand Down Expand Up @@ -998,7 +999,11 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
if (implicitSystemScope !== undefined) {
yield* Scope.close(implicitSystemScope, Exit.void);
}
}).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid);
}).pipe(
Effect.provide(serviceContext),
Effect.withSpan("effect-machine.actor.stop"),
Effect.asVoid,
);

// Track whether hydrate was provided — skip recovery when hydrated
const isHydrated = options?.initialState !== undefined;
Expand Down Expand Up @@ -1078,7 +1083,11 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
if (currentRuntime !== undefined) {
yield* currentRuntime.start;
}
}).pipe(Effect.withSpan("effect-machine.actor.start"), Effect.asVoid);
}).pipe(
Effect.provide(serviceContext),
Effect.withSpan("effect-machine.actor.start"),
Effect.asVoid,
);

return buildActorRefCore(
id,
Expand Down
8 changes: 4 additions & 4 deletions src/internal/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
) {
const { actorId, hooks, lifecycle } = config;

// Capture context for fire-and-forget Deferred settlement (runForkWith)
const services = yield* Effect.context();
// Capture services at allocation so start, stop, and deferred settlement retain them.
const services = yield* Effect.context<R>();
const fork = Effect.runForkWith(services);

// Resources: use cell-provided or allocate fresh
Expand Down Expand Up @@ -494,8 +494,8 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function

return {
...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope),
stop,
start,
stop: stop.pipe(Effect.provide(services)),
start: start.pipe(Effect.provide(services)),
};
});

Expand Down
15 changes: 15 additions & 0 deletions test/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const streamMachine = Machine.make({
)
.final(StreamState.Done);

const GreetingLive = Layer.succeed(GreetingService, {
greet: (name) => Effect.succeed(`Hello, ${name}!`),
});

describe("service requirements", () => {
it.scopedLive("runs state tasks with services supplied by a Layer", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -105,6 +109,17 @@ describe("service requirements", () => {
),
);

it.scopedLive("preserves services provided around actor allocation for start", () =>
Effect.gen(function* () {
const actor = yield* Machine.spawn(streamMachine).pipe(Effect.provide(GreetingLive));
yield* actor.start;
yield* actor.send(StreamEvent.Start);

const state = yield* actor.awaitFinal;
expect(state).toEqual(StreamState.Done({ message: "Hello, Grace!" }));
}),
);

it.scopedLive("propagates service requirements through ActorSystem.spawn", () =>
Effect.gen(function* () {
const system = yield* ActorSystemService;
Expand Down
Loading