@yolk-sdk/harness 0.1.0-canary.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/dist/coordinator.d.mts +72 -0
  4. package/dist/coordinator.d.mts.map +1 -0
  5. package/dist/coordinator.mjs +154 -0
  6. package/dist/coordinator.mjs.map +1 -0
  7. package/dist/driver/durable-object.d.mts +20 -0
  8. package/dist/driver/durable-object.d.mts.map +1 -0
  9. package/dist/driver/durable-object.mjs +12 -0
  10. package/dist/driver/durable-object.mjs.map +1 -0
  11. package/dist/driver/memory.d.mts +21 -0
  12. package/dist/driver/memory.d.mts.map +1 -0
  13. package/dist/driver/memory.mjs +15 -0
  14. package/dist/driver/memory.mjs.map +1 -0
  15. package/dist/driver.d.mts +126 -0
  16. package/dist/driver.d.mts.map +1 -0
  17. package/dist/driver.mjs +96 -0
  18. package/dist/driver.mjs.map +1 -0
  19. package/dist/inbox.d.mts +368 -0
  20. package/dist/inbox.d.mts.map +1 -0
  21. package/dist/inbox.mjs +235 -0
  22. package/dist/inbox.mjs.map +1 -0
  23. package/dist/index.d.mts +1 -0
  24. package/dist/index.mjs +1 -0
  25. package/dist/outcome-constructors-internal.d.mts +403 -0
  26. package/dist/outcome-constructors-internal.d.mts.map +1 -0
  27. package/dist/outcome-constructors-internal.mjs +11 -0
  28. package/dist/outcome-constructors-internal.mjs.map +1 -0
  29. package/dist/outcome.d.mts +361 -0
  30. package/dist/outcome.d.mts.map +1 -0
  31. package/dist/outcome.mjs +136 -0
  32. package/dist/outcome.mjs.map +1 -0
  33. package/dist/store.d.mts +39 -0
  34. package/dist/store.d.mts.map +1 -0
  35. package/dist/store.mjs +98 -0
  36. package/dist/store.mjs.map +1 -0
  37. package/package.json +90 -0
  38. package/src/coordinator.ts +355 -0
  39. package/src/driver/durable-object.ts +28 -0
  40. package/src/driver/memory.ts +39 -0
  41. package/src/driver.ts +270 -0
  42. package/src/inbox.ts +528 -0
  43. package/src/index.ts +2 -0
  44. package/src/outcome-constructors-internal.ts +18 -0
  45. package/src/outcome.ts +377 -0
  46. package/src/store.ts +161 -0
package/dist/store.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import { Context, Effect, Layer, Ref, Semaphore } from "effect";
2
+ //#region src/store.ts
3
+ const emptySnapshot = {
4
+ claimed: [],
5
+ resumes: []
6
+ };
7
+ const snapshotFromMemory = (memory) => ({
8
+ claimed: [...memory.claimed],
9
+ resumes: [...memory.resumes.entries()]
10
+ });
11
+ const memoryFromSnapshot = (snapshot) => ({
12
+ claimed: new Set(snapshot.claimed),
13
+ resumes: new Map(snapshot.resumes)
14
+ });
15
+ var RunStore = class RunStore extends Context.Service()("@yolk-sdk/harness/RunStore") {
16
+ /**
17
+ * Canonical owning layer for snapshot-backed claims. Each call builds a fresh layer so
18
+ * composed harnesses never share `Ref` state or the snapshot lock across factory calls.
19
+ */
20
+ static snapshotLayer = (options) => Layer.effect(this, Effect.gen(function* () {
21
+ const loaded = yield* options.load;
22
+ const memory = yield* Ref.make(memoryFromSnapshot(loaded ?? emptySnapshot));
23
+ const lock = yield* Semaphore.make(1);
24
+ const mutate = (effect) => Effect.uninterruptibleMask((restore) => restore(lock.take(1)).pipe(Effect.flatMap(() => effect.pipe(Effect.ensuring(lock.release(1))))));
25
+ const commit = (next) => options.save(snapshotFromMemory(next)).pipe(Effect.flatMap(() => Ref.set(memory, next)));
26
+ return RunStore.of({
27
+ claim: (runId) => mutate(Effect.gen(function* () {
28
+ const current = yield* Ref.get(memory);
29
+ yield* commit({
30
+ claimed: new Set(current.claimed).add(runId),
31
+ resumes: current.resumes
32
+ });
33
+ })),
34
+ release: (runId) => mutate(Effect.gen(function* () {
35
+ const current = yield* Ref.get(memory);
36
+ const claimed = new Set(current.claimed);
37
+ claimed.delete(runId);
38
+ const resumes = new Map(current.resumes);
39
+ resumes.delete(runId);
40
+ yield* commit({
41
+ claimed,
42
+ resumes
43
+ });
44
+ })),
45
+ isClaimed: (runId) => Ref.get(memory).pipe(Effect.map((current) => current.claimed.has(runId))),
46
+ claimed: Ref.get(memory).pipe(Effect.map((current) => new Set(current.claimed))),
47
+ incrementResumeCount: (runId) => mutate(Effect.gen(function* () {
48
+ const current = yield* Ref.get(memory);
49
+ const nextCount = (current.resumes.get(runId) ?? 0) + 1;
50
+ const resumes = new Map(current.resumes);
51
+ resumes.set(runId, nextCount);
52
+ yield* commit({
53
+ claimed: current.claimed,
54
+ resumes
55
+ });
56
+ return nextCount;
57
+ })),
58
+ resumeCount: (runId) => Ref.get(memory).pipe(Effect.map((current) => current.resumes.get(runId) ?? 0))
59
+ });
60
+ }));
61
+ /**
62
+ * Canonical owning layer for process-local claims. Each call builds a fresh layer so
63
+ * composed harnesses never share `Ref` state across factory calls.
64
+ */
65
+ static inMemoryLayer = () => Layer.effect(this, Effect.gen(function* () {
66
+ const claimed = yield* Ref.make(/* @__PURE__ */ new Set());
67
+ const resumes = yield* Ref.make(/* @__PURE__ */ new Map());
68
+ return RunStore.of({
69
+ claim: (runId) => Ref.update(claimed, (current) => new Set(current).add(runId)),
70
+ release: (runId) => Effect.zip(Ref.update(claimed, (current) => {
71
+ const next = new Set(current);
72
+ next.delete(runId);
73
+ return next;
74
+ }), Ref.update(resumes, (current) => {
75
+ const next = new Map(current);
76
+ next.delete(runId);
77
+ return next;
78
+ })).pipe(Effect.asVoid),
79
+ isClaimed: (runId) => Ref.get(claimed).pipe(Effect.map((current) => current.has(runId))),
80
+ claimed: Ref.get(claimed).pipe(Effect.map((current) => new Set(current))),
81
+ incrementResumeCount: (runId) => Ref.modify(resumes, (current) => {
82
+ const nextCount = (current.get(runId) ?? 0) + 1;
83
+ const next = new Map(current);
84
+ next.set(runId, nextCount);
85
+ return [nextCount, next];
86
+ }),
87
+ resumeCount: (runId) => Ref.get(resumes).pipe(Effect.map((current) => current.get(runId) ?? 0))
88
+ });
89
+ }));
90
+ };
91
+ /** Backward-compatible delegation to {@link RunStore.snapshotLayer}. */
92
+ const makeSnapshotRunStoreLayer = (options) => RunStore.snapshotLayer(options);
93
+ /** Backward-compatible delegation to {@link RunStore.inMemoryLayer}. */
94
+ const makeInMemoryRunStoreLayer = () => RunStore.inMemoryLayer();
95
+ //#endregion
96
+ export { RunStore, makeInMemoryRunStoreLayer, makeSnapshotRunStoreLayer };
97
+
98
+ //# sourceMappingURL=store.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.mjs","names":[],"sources":["../src/store.ts"],"sourcesContent":["import { Context, Effect, Layer, Ref, Semaphore } from 'effect'\n\nexport type RunStoreApi = {\n readonly claim: (runId: string) => Effect.Effect<void>\n readonly release: (runId: string) => Effect.Effect<void>\n readonly isClaimed: (runId: string) => Effect.Effect<boolean>\n readonly claimed: Effect.Effect<ReadonlySet<string>>\n readonly incrementResumeCount: (runId: string) => Effect.Effect<number>\n readonly resumeCount: (runId: string) => Effect.Effect<number>\n}\n\nexport type SnapshotRunStoreOptions = {\n readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>\n readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>\n}\n\nexport type DurableRunStoreSnapshot = {\n readonly claimed: ReadonlyArray<string>\n readonly resumes: ReadonlyArray<readonly [string, number]>\n}\n\ntype MemorySnapshot = {\n readonly claimed: ReadonlySet<string>\n readonly resumes: ReadonlyMap<string, number>\n}\n\nconst emptySnapshot: DurableRunStoreSnapshot = {\n claimed: [],\n resumes: []\n}\n\nconst snapshotFromMemory = (memory: MemorySnapshot): DurableRunStoreSnapshot => ({\n claimed: [...memory.claimed],\n resumes: [...memory.resumes.entries()]\n})\n\nconst memoryFromSnapshot = (snapshot: DurableRunStoreSnapshot): MemorySnapshot => ({\n claimed: new Set(snapshot.claimed),\n resumes: new Map(snapshot.resumes)\n})\n\nexport class RunStore extends Context.Service<RunStore, RunStoreApi>()(\n '@yolk-sdk/harness/RunStore'\n) {\n /**\n * Canonical owning layer for snapshot-backed claims. Each call builds a fresh layer so\n * composed harnesses never share `Ref` state or the snapshot lock across factory calls.\n */\n static snapshotLayer = (options: SnapshotRunStoreOptions): Layer.Layer<RunStore> =>\n Layer.effect(\n this,\n Effect.gen(function* () {\n const loaded = yield* options.load\n const memory = yield* Ref.make(memoryFromSnapshot(loaded ?? emptySnapshot))\n const lock = yield* Semaphore.make(1)\n\n const mutate = <A, E, R>(effect: Effect.Effect<A, E, R>) =>\n Effect.uninterruptibleMask(restore =>\n restore(lock.take(1)).pipe(\n Effect.flatMap(() => effect.pipe(Effect.ensuring(lock.release(1))))\n )\n )\n\n const commit = (next: MemorySnapshot) =>\n options.save(snapshotFromMemory(next)).pipe(Effect.flatMap(() => Ref.set(memory, next)))\n\n return RunStore.of({\n claim: runId =>\n mutate(\n Effect.gen(function* () {\n const current = yield* Ref.get(memory)\n yield* commit({\n claimed: new Set(current.claimed).add(runId),\n resumes: current.resumes\n })\n })\n ),\n release: runId =>\n mutate(\n Effect.gen(function* () {\n const current = yield* Ref.get(memory)\n const claimed = new Set(current.claimed)\n claimed.delete(runId)\n const resumes = new Map(current.resumes)\n resumes.delete(runId)\n yield* commit({ claimed, resumes })\n })\n ),\n isClaimed: runId =>\n Ref.get(memory).pipe(Effect.map(current => current.claimed.has(runId))),\n claimed: Ref.get(memory).pipe(Effect.map(current => new Set(current.claimed))),\n incrementResumeCount: runId =>\n mutate(\n Effect.gen(function* () {\n const current = yield* Ref.get(memory)\n const nextCount = (current.resumes.get(runId) ?? 0) + 1\n const resumes = new Map(current.resumes)\n resumes.set(runId, nextCount)\n yield* commit({ claimed: current.claimed, resumes })\n\n return nextCount\n })\n ),\n resumeCount: runId =>\n Ref.get(memory).pipe(Effect.map(current => current.resumes.get(runId) ?? 0))\n })\n })\n )\n\n /**\n * Canonical owning layer for process-local claims. Each call builds a fresh layer so\n * composed harnesses never share `Ref` state across factory calls.\n */\n static inMemoryLayer = (): Layer.Layer<RunStore> =>\n Layer.effect(\n this,\n Effect.gen(function* () {\n const claimed = yield* Ref.make(new Set<string>())\n const resumes = yield* Ref.make(new Map<string, number>())\n\n return RunStore.of({\n claim: runId => Ref.update(claimed, current => new Set(current).add(runId)),\n release: runId =>\n Effect.zip(\n Ref.update(claimed, current => {\n const next = new Set(current)\n next.delete(runId)\n\n return next\n }),\n Ref.update(resumes, current => {\n const next = new Map(current)\n next.delete(runId)\n\n return next\n })\n ).pipe(Effect.asVoid),\n isClaimed: runId => Ref.get(claimed).pipe(Effect.map(current => current.has(runId))),\n claimed: Ref.get(claimed).pipe(Effect.map(current => new Set(current))),\n incrementResumeCount: runId =>\n Ref.modify(resumes, current => {\n const nextCount = (current.get(runId) ?? 0) + 1\n const next = new Map(current)\n next.set(runId, nextCount)\n\n return [nextCount, next] as const\n }),\n resumeCount: runId =>\n Ref.get(resumes).pipe(Effect.map(current => current.get(runId) ?? 0))\n })\n })\n )\n}\n\n/** Backward-compatible delegation to {@link RunStore.snapshotLayer}. */\nexport const makeSnapshotRunStoreLayer = (\n options: SnapshotRunStoreOptions\n): Layer.Layer<RunStore> => RunStore.snapshotLayer(options)\n\n/** Backward-compatible delegation to {@link RunStore.inMemoryLayer}. */\nexport const makeInMemoryRunStoreLayer = (): Layer.Layer<RunStore> => RunStore.inMemoryLayer()\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@yolk-sdk/harness",
3
+ "version": "0.1.0-canary.100",
4
+ "description": "Domain-free agent run lifecycle: coordinator, store, inbox, and pluggable drivers.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/magoz/yolk-sdk.git",
11
+ "directory": "packages/harness"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/magoz/yolk-sdk/issues"
15
+ },
16
+ "homepage": "https://github.com/magoz/yolk-sdk#readme",
17
+ "keywords": [
18
+ "agent",
19
+ "harness",
20
+ "lifecycle",
21
+ "effect"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "exports": {
27
+ "./package.json": "./package.json",
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs",
31
+ "default": "./dist/index.mjs"
32
+ },
33
+ "./coordinator": {
34
+ "types": "./dist/coordinator.d.mts",
35
+ "import": "./dist/coordinator.mjs",
36
+ "default": "./dist/coordinator.mjs"
37
+ },
38
+ "./store": {
39
+ "types": "./dist/store.d.mts",
40
+ "import": "./dist/store.mjs",
41
+ "default": "./dist/store.mjs"
42
+ },
43
+ "./inbox": {
44
+ "types": "./dist/inbox.d.mts",
45
+ "import": "./dist/inbox.mjs",
46
+ "default": "./dist/inbox.mjs"
47
+ },
48
+ "./driver": {
49
+ "types": "./dist/driver.d.mts",
50
+ "import": "./dist/driver.mjs",
51
+ "default": "./dist/driver.mjs"
52
+ },
53
+ "./driver/memory": {
54
+ "types": "./dist/driver/memory.d.mts",
55
+ "import": "./dist/driver/memory.mjs",
56
+ "default": "./dist/driver/memory.mjs"
57
+ },
58
+ "./driver/durable-object": {
59
+ "types": "./dist/driver/durable-object.d.mts",
60
+ "import": "./dist/driver/durable-object.mjs",
61
+ "default": "./dist/driver/durable-object.mjs"
62
+ },
63
+ "./outcome": {
64
+ "types": "./dist/outcome.d.mts",
65
+ "import": "./dist/outcome.mjs",
66
+ "default": "./dist/outcome.mjs"
67
+ }
68
+ },
69
+ "files": [
70
+ "src/**/*.ts",
71
+ "!src/**/*.test.ts",
72
+ "!src/**/*.test.tsx",
73
+ "dist/**/*",
74
+ "README.md"
75
+ ],
76
+ "publishConfig": {
77
+ "access": "public",
78
+ "provenance": true
79
+ },
80
+ "dependencies": {
81
+ "effect": "4.0.0",
82
+ "@yolk-sdk/agent": "^0.1.0-canary.100"
83
+ },
84
+ "scripts": {
85
+ "build": "tsdown",
86
+ "check": "tsc -p tsconfig.json --noEmit",
87
+ "test": "vitest run --passWithNoTests",
88
+ "test:run": "vitest run --passWithNoTests"
89
+ }
90
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Process-local run coordinator.
3
+ *
4
+ * Port of OpenCode v2 `SessionRunCoordinator` (MIT): one busy period per key,
5
+ * a doorbell that coalesces wakes, and interrupt that claims pending wakes so a
6
+ * dead intent cannot restart. Steering lands at the next drain boundary.
7
+ *
8
+ * @see https://github.com/sst/opencode (packages/core/src/session/run-coordinator.ts)
9
+ */
10
+ import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer } from 'effect'
11
+ import type { Scope } from 'effect'
12
+ import { StopReceipt as stopReceipt } from './outcome-constructors-internal.ts'
13
+ import { RunStore } from './store.ts'
14
+
15
+ // Private settlement receipt: succeed the Deferred with Exit as a value so
16
+ // interrupt fan-out does not skip Effect 4.0.0-beta.80 Deferred listeners.
17
+ // Public waiters flatten that Exit; this is not a global Deferred fix.
18
+ const awaitDone = <E>(done: Deferred.Deferred<Exit.Exit<void, E>>): Effect.Effect<void, E> =>
19
+ Deferred.await(done).pipe(Effect.flatMap(exit => exit))
20
+
21
+ /** `"input"` subsumes `"steer"` when coalescing wakes. */
22
+ export type Promotable = 'input' | 'steer'
23
+
24
+ /** Shutdown retains the claim; explicit user stop releases it after settlement. */
25
+ export type InterruptReason = 'user' | 'shutdown'
26
+
27
+ export type CapturedRun<E> =
28
+ | { readonly _tag: 'Started'; readonly join: Effect.Effect<void, E> }
29
+ | { readonly _tag: 'Joined'; readonly join: Effect.Effect<void, E> }
30
+ | { readonly _tag: 'Stopping'; readonly awaitSettlement: Effect.Effect<void> }
31
+
32
+ export type StopReceipt =
33
+ | { readonly _tag: 'Idle' }
34
+ | { readonly _tag: 'Interrupted' }
35
+ | { readonly _tag: 'LiveStopping' }
36
+ | { readonly _tag: 'Settling' }
37
+
38
+ export type Coordinator<Key, E, Reason = never> = {
39
+ readonly active: Effect.Effect<ReadonlySet<Key>>
40
+ readonly isActive: (key: Key) => Effect.Effect<boolean>
41
+ /** Starts an execution while idle, or joins the active execution. */
42
+ readonly run: (key: Key) => Effect.Effect<void, E>
43
+ /**
44
+ * Captures a run waiter without awaiting settlement.
45
+ * Idle starts force=true (`Started`); an active owner is joined (`Joined`),
46
+ * including during natural settlement. A stopping owner yields `Stopping` with a
47
+ * settlement waiter and no start.
48
+ */
49
+ readonly captureRun: (key: Key) => Effect.Effect<CapturedRun<E>>
50
+ /** Rings the doorbell: idle starts; active drains again before settling. */
51
+ readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
52
+ /**
53
+ * Stops the active execution and clears its doorbell. No-op when idle.
54
+ * Resolves once the interruption request is delivered, not when cleanup settles.
55
+ * First accepted `reason` is kept; use `terminalStop` to escalate.
56
+ */
57
+ readonly interrupt: (
58
+ key: Key,
59
+ reason?: Reason,
60
+ options?: { readonly awaitSettlement?: boolean }
61
+ ) => Effect.Effect<boolean>
62
+ /**
63
+ * Terminal stop receipt. Always clears pendingWake. Settling means the owner
64
+ * is already gone and the settled callback is chosen; Driver may release the
65
+ * claim under the Inbox gate. LiveStopping/Interrupted leave release to that
66
+ * owner's future settled callback. LiveStopping joins the same outstanding
67
+ * interruption request rather than forking a second one.
68
+ */
69
+ readonly terminalStop: (key: Key, reason: Reason) => Effect.Effect<StopReceipt>
70
+ /** Resolves once no execution is active. Never starts work. */
71
+ readonly awaitIdle: (key: Key) => Effect.Effect<void>
72
+ }
73
+
74
+ type Execution<E, Reason> = {
75
+ readonly done: Deferred.Deferred<Exit.Exit<void, E>>
76
+ owner?: Fiber.Fiber<void>
77
+ request?: Fiber.Fiber<void>
78
+ scope: Promotable
79
+ pendingWake?: Promotable
80
+ stopping: boolean
81
+ interruptionReason?: Reason
82
+ }
83
+
84
+ const widerScope = (current: Promotable | undefined, next: Promotable): Promotable =>
85
+ current === 'input' || next === 'input' ? 'input' : 'steer'
86
+
87
+ export const makeCoordinator = <Key, E, Reason = never>(options: {
88
+ readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
89
+ readonly started?: (key: Key) => Effect.Effect<void>
90
+ readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
91
+ }): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
92
+ Effect.gen(function* () {
93
+ const executions = new Map<Key, Execution<E, Reason>>()
94
+ const fork = yield* FiberSet.makeRuntime<never, void, never>()
95
+
96
+ const loop = (
97
+ key: Key,
98
+ execution: Execution<E, Reason>,
99
+ force: boolean
100
+ ): Effect.Effect<void, E> =>
101
+ Effect.suspend(() => {
102
+ if (execution.stopping) return Effect.void
103
+
104
+ return options.drain(key, force, execution.scope)
105
+ }).pipe(
106
+ Effect.andThen(
107
+ Effect.suspend(() => {
108
+ if (execution.stopping || execution.pendingWake === undefined) return Effect.void
109
+ execution.scope = execution.pendingWake
110
+ execution.pendingWake = undefined
111
+
112
+ return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
113
+ })
114
+ )
115
+ )
116
+
117
+ const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
118
+ if (execution.pendingWake !== undefined) start(key, false, execution.pendingWake)
119
+ else executions.delete(key)
120
+
121
+ return Deferred.succeed(execution.done, exit).pipe(Effect.asVoid)
122
+ }
123
+
124
+ const start = (key: Key, force: boolean, scope: Promotable) => {
125
+ const execution: Execution<E, Reason> = {
126
+ done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
127
+ scope,
128
+ stopping: false
129
+ }
130
+
131
+ executions.set(key, execution)
132
+ execution.owner = fork(
133
+ Effect.yieldNow.pipe(
134
+ Effect.andThen(
135
+ Effect.uninterruptible(Effect.suspend(() => options.started?.(key) ?? Effect.void))
136
+ ),
137
+ Effect.andThen(loop(key, execution, force)),
138
+ Effect.onExit(exit =>
139
+ Effect.suspend(() => {
140
+ execution.owner = undefined
141
+
142
+ return options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void
143
+ })
144
+ ),
145
+ Effect.onExit(exit => settle(key, execution, exit)),
146
+ Effect.exit,
147
+ Effect.asVoid
148
+ )
149
+ )
150
+
151
+ return execution
152
+ }
153
+
154
+ const forkInterruptionRequest = (owner: Fiber.Fiber<void>): Fiber.Fiber<void> =>
155
+ fork(Effect.yieldNow.pipe(Effect.andThen(Effect.sync(() => owner.interruptUnsafe()))))
156
+
157
+ const interruptNow = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
158
+ Effect.suspend(() => {
159
+ const execution = executions.get(key)
160
+
161
+ if (execution === undefined || execution.stopping) return Effect.succeed(false)
162
+
163
+ if (execution.owner === undefined) {
164
+ execution.pendingWake = undefined
165
+
166
+ return Effect.succeed(false)
167
+ }
168
+
169
+ const owner = execution.owner
170
+ execution.stopping = true
171
+ execution.pendingWake = undefined
172
+ execution.interruptionReason = reason
173
+ // Capture the exact old owner and fork the request in this same lazy
174
+ // transition so cancellation cannot leave stopping=true with no request.
175
+ // Initial yield breaks owner -> resumed caller -> owner reentrancy.
176
+ // Join the request fiber only; interruptUnsafe may run interruptible
177
+ // finalizers inline until they suspend. Do not await owner settlement.
178
+ const request = forkInterruptionRequest(owner)
179
+ execution.request = request
180
+
181
+ return Fiber.join(request).pipe(Effect.as(true))
182
+ })
183
+
184
+ const awaitIdle = (key: Key): Effect.Effect<void> =>
185
+ Effect.suspend(() => {
186
+ const execution = executions.get(key)
187
+
188
+ if (execution === undefined) return Effect.void
189
+
190
+ return awaitDone(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
191
+ })
192
+
193
+ return {
194
+ active: Effect.sync(() => new Set(executions.keys())),
195
+ isActive: key => Effect.sync(() => executions.has(key)),
196
+ run: key =>
197
+ Effect.suspend(() => {
198
+ const execution = executions.get(key)
199
+
200
+ if (execution === undefined) return awaitDone(start(key, true, 'input').done)
201
+
202
+ if (!execution.stopping) return awaitDone(execution.done)
203
+
204
+ return awaitDone(execution.done).pipe(
205
+ Effect.ignoreCause,
206
+ Effect.andThen(
207
+ Effect.suspend(() => {
208
+ const next = executions.get(key)
209
+
210
+ return next === undefined
211
+ ? awaitDone(start(key, true, 'input').done)
212
+ : awaitDone(next.done)
213
+ })
214
+ )
215
+ )
216
+ }),
217
+ captureRun: key =>
218
+ Effect.sync(() => {
219
+ const execution = executions.get(key)
220
+
221
+ if (execution === undefined) {
222
+ const started = start(key, true, 'input')
223
+
224
+ return { _tag: 'Started' as const, join: awaitDone(started.done) }
225
+ }
226
+
227
+ if (!execution.stopping) {
228
+ return { _tag: 'Joined' as const, join: awaitDone(execution.done) }
229
+ }
230
+
231
+ return {
232
+ _tag: 'Stopping' as const,
233
+ awaitSettlement: awaitDone(execution.done).pipe(Effect.ignoreCause)
234
+ }
235
+ }),
236
+ wake: (key, scope = 'input') =>
237
+ Effect.sync(() => {
238
+ const execution = executions.get(key)
239
+
240
+ if (execution !== undefined) {
241
+ execution.pendingWake = widerScope(execution.pendingWake, scope)
242
+
243
+ return
244
+ }
245
+
246
+ start(key, false, scope)
247
+ }),
248
+ interrupt: (key, reason, options) =>
249
+ Effect.suspend(() => {
250
+ const execution = executions.get(key)
251
+
252
+ return interruptNow(key, reason).pipe(
253
+ Effect.tap(() =>
254
+ options?.awaitSettlement === true && execution !== undefined
255
+ ? awaitDone(execution.done).pipe(Effect.ignoreCause)
256
+ : Effect.void
257
+ )
258
+ )
259
+ }),
260
+ terminalStop: (key, reason) =>
261
+ Effect.suspend((): Effect.Effect<StopReceipt> => {
262
+ const execution = executions.get(key)
263
+
264
+ if (execution === undefined) return Effect.succeed(stopReceipt.Idle())
265
+ execution.pendingWake = undefined
266
+
267
+ if (execution.owner === undefined) {
268
+ execution.stopping = true
269
+
270
+ return Effect.succeed(stopReceipt.Settling())
271
+ }
272
+
273
+ execution.interruptionReason = reason
274
+
275
+ if (execution.stopping) {
276
+ const request = execution.request
277
+
278
+ return request === undefined
279
+ ? Effect.succeed(stopReceipt.LiveStopping())
280
+ : Fiber.join(request).pipe(Effect.as(stopReceipt.LiveStopping()))
281
+ }
282
+
283
+ const owner = execution.owner
284
+ execution.stopping = true
285
+ const request = forkInterruptionRequest(owner)
286
+ execution.request = request
287
+
288
+ return Fiber.join(request).pipe(Effect.as(stopReceipt.Interrupted()))
289
+ }),
290
+ awaitIdle
291
+ }
292
+ })
293
+
294
+ /** Owning coordinator layer, with per-acquisition claim receipts and settlement policy. */
295
+ export class RunCoordinator extends Context.Service<
296
+ RunCoordinator,
297
+ Coordinator<string, never, InterruptReason>
298
+ >()('@yolk-sdk/harness/RunCoordinator') {
299
+ static layer = (options?: {
300
+ readonly drain?: (runId: string, force: boolean, scope: Promotable) => Effect.Effect<void>
301
+ }): Layer.Layer<RunCoordinator, never, RunStore> =>
302
+ Layer.effect(
303
+ this,
304
+ Effect.gen(function* () {
305
+ const store = yield* RunStore
306
+ const drain = options?.drain ?? ((_runId, _force, _scope) => Effect.void)
307
+ const acquired = new Set<string>()
308
+
309
+ return yield* makeCoordinator<string, never, InterruptReason>({
310
+ drain,
311
+ started: runId =>
312
+ store.claim(runId).pipe(
313
+ Effect.tap(() =>
314
+ Effect.sync(() => {
315
+ acquired.add(runId)
316
+ })
317
+ )
318
+ ),
319
+ settled: (runId, exit, reason) =>
320
+ Effect.suspend(() => {
321
+ const owned = acquired.delete(runId)
322
+
323
+ if (
324
+ owned &&
325
+ (reason === 'user' || (reason === undefined && !Exit.hasInterrupts(exit)))
326
+ ) {
327
+ return store.release(runId)
328
+ }
329
+
330
+ // Acquisition receipt guards automatic failed-start settlement only.
331
+ // Explicit user-terminal authority still releases a leftover claim
332
+ // after this owner has actually settled, matching Idle/Settling stop.
333
+ if (reason === 'user' && !owned) {
334
+ return store
335
+ .isClaimed(runId)
336
+ .pipe(Effect.flatMap(claimed => (claimed ? store.release(runId) : Effect.void)))
337
+ }
338
+
339
+ return Effect.void
340
+ }).pipe(
341
+ Effect.exit,
342
+ Effect.flatMap(settledExit => {
343
+ if (Exit.isSuccess(settledExit)) return Effect.void
344
+
345
+ return Effect.failCause(
346
+ Exit.isFailure(exit)
347
+ ? Cause.combine(exit.cause, settledExit.cause)
348
+ : settledExit.cause
349
+ )
350
+ })
351
+ )
352
+ })
353
+ })
354
+ )
355
+ }
@@ -0,0 +1,28 @@
1
+ import { Layer } from 'effect'
2
+ import type { Effect } from 'effect'
3
+ import { Driver, type Drain, type InvalidMaxResumeAttempts } from '../driver.ts'
4
+ import { Inbox } from '../inbox.ts'
5
+ import { RunStore, type DurableRunStoreSnapshot } from '../store.ts'
6
+
7
+ export type { DurableRunStoreSnapshot }
8
+
9
+ type DurableObjectDriverBase = {
10
+ readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>
11
+ readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>
12
+ readonly drain?: Drain
13
+ }
14
+
15
+ export function makeDurableObjectDriverLayer(
16
+ options: DurableObjectDriverBase & { readonly maxResumeAttempts?: undefined }
17
+ ): Layer.Layer<Driver | RunStore | Inbox>
18
+ export function makeDurableObjectDriverLayer(
19
+ options: DurableObjectDriverBase & { readonly maxResumeAttempts?: number }
20
+ ): Layer.Layer<Driver | RunStore | Inbox, InvalidMaxResumeAttempts>
21
+ export function makeDurableObjectDriverLayer(
22
+ options: DurableObjectDriverBase & { readonly maxResumeAttempts?: number }
23
+ ) {
24
+ return Driver.coordinatedLayer(options).pipe(
25
+ Layer.provideMerge(RunStore.snapshotLayer(options)),
26
+ Layer.provideMerge(Inbox.layer())
27
+ )
28
+ }
@@ -0,0 +1,39 @@
1
+ import { Layer } from 'effect'
2
+ import {
3
+ Driver,
4
+ type Drain,
5
+ type DriverLayerOptions,
6
+ type InvalidMaxResumeAttempts
7
+ } from '../driver.ts'
8
+ import { Inbox } from '../inbox.ts'
9
+ import { RunStore } from '../store.ts'
10
+
11
+ export function makeInMemoryDriverLayer(): Layer.Layer<Driver, never, RunStore | Inbox>
12
+ export function makeInMemoryDriverLayer(options?: {
13
+ readonly drain?: Drain
14
+ readonly maxResumeAttempts?: undefined
15
+ }): Layer.Layer<Driver, never, RunStore | Inbox>
16
+ export function makeInMemoryDriverLayer(
17
+ options?: DriverLayerOptions
18
+ ): Layer.Layer<Driver, InvalidMaxResumeAttempts, RunStore | Inbox>
19
+ export function makeInMemoryDriverLayer(options?: DriverLayerOptions) {
20
+ return options === undefined ? Driver.coordinatedLayer() : Driver.coordinatedLayer(options)
21
+ }
22
+
23
+ export function makeInMemoryHarnessLayer(): Layer.Layer<Driver | RunStore | Inbox>
24
+ export function makeInMemoryHarnessLayer(options?: {
25
+ readonly drain?: Drain
26
+ readonly maxResumeAttempts?: undefined
27
+ }): Layer.Layer<Driver | RunStore | Inbox>
28
+ export function makeInMemoryHarnessLayer(
29
+ options?: DriverLayerOptions
30
+ ): Layer.Layer<Driver | RunStore | Inbox, InvalidMaxResumeAttempts>
31
+ export function makeInMemoryHarnessLayer(options?: DriverLayerOptions) {
32
+ const driver =
33
+ options === undefined ? Driver.coordinatedLayer() : Driver.coordinatedLayer(options)
34
+
35
+ return driver.pipe(
36
+ Layer.provideMerge(RunStore.inMemoryLayer()),
37
+ Layer.provideMerge(Inbox.layer())
38
+ )
39
+ }