@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/dist/coordinator.d.mts +72 -0
- package/dist/coordinator.d.mts.map +1 -0
- package/dist/coordinator.mjs +154 -0
- package/dist/coordinator.mjs.map +1 -0
- package/dist/driver/durable-object.d.mts +20 -0
- package/dist/driver/durable-object.d.mts.map +1 -0
- package/dist/driver/durable-object.mjs +12 -0
- package/dist/driver/durable-object.mjs.map +1 -0
- package/dist/driver/memory.d.mts +21 -0
- package/dist/driver/memory.d.mts.map +1 -0
- package/dist/driver/memory.mjs +15 -0
- package/dist/driver/memory.mjs.map +1 -0
- package/dist/driver.d.mts +126 -0
- package/dist/driver.d.mts.map +1 -0
- package/dist/driver.mjs +96 -0
- package/dist/driver.mjs.map +1 -0
- package/dist/inbox.d.mts +368 -0
- package/dist/inbox.d.mts.map +1 -0
- package/dist/inbox.mjs +235 -0
- package/dist/inbox.mjs.map +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/outcome-constructors-internal.d.mts +403 -0
- package/dist/outcome-constructors-internal.d.mts.map +1 -0
- package/dist/outcome-constructors-internal.mjs +11 -0
- package/dist/outcome-constructors-internal.mjs.map +1 -0
- package/dist/outcome.d.mts +361 -0
- package/dist/outcome.d.mts.map +1 -0
- package/dist/outcome.mjs +136 -0
- package/dist/outcome.mjs.map +1 -0
- package/dist/store.d.mts +39 -0
- package/dist/store.d.mts.map +1 -0
- package/dist/store.mjs +98 -0
- package/dist/store.mjs.map +1 -0
- package/package.json +90 -0
- package/src/coordinator.ts +355 -0
- package/src/driver/durable-object.ts +28 -0
- package/src/driver/memory.ts +39 -0
- package/src/driver.ts +270 -0
- package/src/inbox.ts +528 -0
- package/src/index.ts +2 -0
- package/src/outcome-constructors-internal.ts +18 -0
- package/src/outcome.ts +377 -0
- package/src/store.ts +161 -0
package/src/outcome.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { Effect, Match, Predicate, Stream, type Data } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
HitlMatch as hitlMatch,
|
|
4
|
+
OverflowCompactionResult as overflowCompactionResult,
|
|
5
|
+
StepOutcome as stepOutcome
|
|
6
|
+
} from './outcome-constructors-internal.ts'
|
|
7
|
+
import {
|
|
8
|
+
addAgentUsage,
|
|
9
|
+
zeroAgentUsage,
|
|
10
|
+
type AgentEvent,
|
|
11
|
+
type AgentMessage,
|
|
12
|
+
type AgentUsage,
|
|
13
|
+
type HitlRequest,
|
|
14
|
+
type HitlResponse
|
|
15
|
+
} from '@yolk-sdk/agent/protocol'
|
|
16
|
+
import {
|
|
17
|
+
collectModelTurnAttempt,
|
|
18
|
+
LLMError,
|
|
19
|
+
runModelTurn,
|
|
20
|
+
runToolBatch,
|
|
21
|
+
type AgentLoopError,
|
|
22
|
+
type ContextTransformer,
|
|
23
|
+
type LLMProvider,
|
|
24
|
+
type LoopConfig,
|
|
25
|
+
type ModelTurnConfig,
|
|
26
|
+
type ModelTurnResult,
|
|
27
|
+
type ToolBatchConfig,
|
|
28
|
+
type ToolExecutor
|
|
29
|
+
} from '@yolk-sdk/agent/loop'
|
|
30
|
+
import {
|
|
31
|
+
applyOverflowCompaction,
|
|
32
|
+
isOverflowCompactionAttemptCount
|
|
33
|
+
} from '@yolk-sdk/agent/compaction'
|
|
34
|
+
|
|
35
|
+
export type OverflowCompactionResult =
|
|
36
|
+
| { readonly _tag: 'Compacted'; readonly messages: ReadonlyArray<AgentMessage> }
|
|
37
|
+
| { readonly _tag: 'Skipped' }
|
|
38
|
+
|
|
39
|
+
export const OverflowCompactionResult = overflowCompactionResult
|
|
40
|
+
|
|
41
|
+
export type StepOutcome = Data.TaggedEnum<{
|
|
42
|
+
Completed: {
|
|
43
|
+
readonly needsContinuation: boolean
|
|
44
|
+
readonly assistantMessage: ModelTurnResult['assistantMessage']
|
|
45
|
+
readonly toolCalls: ModelTurnResult['toolCalls']
|
|
46
|
+
readonly usage: ModelTurnResult['usage']
|
|
47
|
+
readonly stopReason: ModelTurnResult['stopReason']
|
|
48
|
+
}
|
|
49
|
+
Retry: {
|
|
50
|
+
readonly error: AgentLoopError
|
|
51
|
+
}
|
|
52
|
+
Continue: {
|
|
53
|
+
readonly error: AgentLoopError
|
|
54
|
+
readonly assistantMessage: ModelTurnResult['assistantMessage']
|
|
55
|
+
readonly toolCalls: ModelTurnResult['toolCalls']
|
|
56
|
+
readonly usage: ModelTurnResult['usage']
|
|
57
|
+
readonly stopReason: ModelTurnResult['stopReason']
|
|
58
|
+
}
|
|
59
|
+
RecoverFull: {
|
|
60
|
+
readonly error: AgentLoopError
|
|
61
|
+
}
|
|
62
|
+
Compacted: {
|
|
63
|
+
readonly messages: ReadonlyArray<AgentMessage>
|
|
64
|
+
readonly overflowCompactionAttempt: number
|
|
65
|
+
}
|
|
66
|
+
AwaitingInput: {
|
|
67
|
+
readonly requests: ReadonlyArray<HitlRequest>
|
|
68
|
+
readonly usage: AgentUsage
|
|
69
|
+
}
|
|
70
|
+
}>
|
|
71
|
+
|
|
72
|
+
export const StepOutcome = stepOutcome
|
|
73
|
+
|
|
74
|
+
export type CompletedTurn = Extract<StepOutcome, { readonly _tag: 'Completed' }>
|
|
75
|
+
|
|
76
|
+
export type ModelTurnOutcome = Exclude<StepOutcome, { readonly _tag: 'AwaitingInput' }>
|
|
77
|
+
|
|
78
|
+
export type ToolBatchOutcome = Extract<
|
|
79
|
+
StepOutcome,
|
|
80
|
+
{ readonly _tag: 'Completed' | 'AwaitingInput' }
|
|
81
|
+
>
|
|
82
|
+
|
|
83
|
+
const completedOutcome = (result: ModelTurnResult): CompletedTurn =>
|
|
84
|
+
StepOutcome.Completed({
|
|
85
|
+
needsContinuation: result.stopReason === 'tool_use',
|
|
86
|
+
assistantMessage: result.assistantMessage,
|
|
87
|
+
toolCalls: result.toolCalls,
|
|
88
|
+
usage: result.usage,
|
|
89
|
+
stopReason: result.stopReason
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const modelTurnResult = (result: ModelTurnResult): ModelTurnResult => ({
|
|
93
|
+
assistantMessage: result.assistantMessage,
|
|
94
|
+
toolCalls: result.toolCalls,
|
|
95
|
+
usage: result.usage,
|
|
96
|
+
stopReason: result.stopReason
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const invalidOverflowCompactionAttemptError = () =>
|
|
100
|
+
new LLMError({
|
|
101
|
+
cause: 'validation_error',
|
|
102
|
+
message: 'overflowCompactionAttempt must be a finite integer >= 0',
|
|
103
|
+
retryable: false
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const isAgentLoopError = (error: unknown): error is AgentLoopError => {
|
|
107
|
+
if (!Predicate.isObjectOrArray(error) || !('_tag' in error)) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tag = error._tag
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
tag === 'LLMError' ||
|
|
115
|
+
tag === 'FauxExhaustedError' ||
|
|
116
|
+
tag === 'ToolError' ||
|
|
117
|
+
tag === 'ContextTransformError' ||
|
|
118
|
+
tag === 'AbortError'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isOverflow = (error: AgentLoopError) =>
|
|
123
|
+
Match.value(error).pipe(
|
|
124
|
+
Match.tag('LLMError', 'ContextTransformError', current => current.cause === 'context_overflow'),
|
|
125
|
+
Match.orElse(() => false)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const isMissingDone = (error: AgentLoopError) =>
|
|
129
|
+
Match.value(error).pipe(
|
|
130
|
+
Match.tag('LLMError', current => current.responseIssue === 'missing_done'),
|
|
131
|
+
Match.orElse(() => false)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const isRetryableLlm = (
|
|
135
|
+
error: AgentLoopError
|
|
136
|
+
): error is Extract<AgentLoopError, { _tag: 'LLMError' }> =>
|
|
137
|
+
Predicate.isTagged(error, 'LLMError') && error.retryable
|
|
138
|
+
|
|
139
|
+
const classifyModelTurnFailure = <E2, R2>(input: {
|
|
140
|
+
readonly error: AgentLoopError
|
|
141
|
+
readonly collected: ModelTurnResult
|
|
142
|
+
readonly outputStarted: boolean
|
|
143
|
+
readonly messages: ReadonlyArray<AgentMessage>
|
|
144
|
+
readonly overflowCompactionAttempt: number
|
|
145
|
+
readonly compact?: (
|
|
146
|
+
messages: ReadonlyArray<AgentMessage>
|
|
147
|
+
) => Effect.Effect<OverflowCompactionResult, E2, R2>
|
|
148
|
+
}): Effect.Effect<ModelTurnOutcome, AgentLoopError | E2, R2> => {
|
|
149
|
+
if (isOverflow(input.error)) {
|
|
150
|
+
if (input.compact === undefined) {
|
|
151
|
+
return Effect.fail(input.error)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return applyOverflowCompaction({
|
|
155
|
+
compact: input.compact,
|
|
156
|
+
messages: input.messages,
|
|
157
|
+
attempt: input.overflowCompactionAttempt,
|
|
158
|
+
outputStarted: input.outputStarted
|
|
159
|
+
}).pipe(
|
|
160
|
+
Effect.flatMap(result =>
|
|
161
|
+
Match.value(result).pipe(
|
|
162
|
+
Match.tag('Compacted', current =>
|
|
163
|
+
Effect.succeed(
|
|
164
|
+
StepOutcome.Compacted({
|
|
165
|
+
messages: current.messages,
|
|
166
|
+
overflowCompactionAttempt: input.overflowCompactionAttempt + 1
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
),
|
|
170
|
+
Match.orElse(() => Effect.fail(input.error))
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isMissingDone(input.error) && !input.outputStarted) {
|
|
177
|
+
return Effect.succeed(StepOutcome.RecoverFull({ error: input.error }))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isMissingDone(input.error) && input.outputStarted) {
|
|
181
|
+
return Effect.succeed(
|
|
182
|
+
StepOutcome.Continue({
|
|
183
|
+
error: input.error,
|
|
184
|
+
...modelTurnResult(input.collected)
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isRetryableLlm(input.error) && !input.outputStarted) {
|
|
190
|
+
if (input.error.cause === 'invalid_response') {
|
|
191
|
+
return Effect.succeed(StepOutcome.RecoverFull({ error: input.error }))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Effect.succeed(StepOutcome.Retry({ error: input.error }))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isRetryableLlm(input.error) && input.outputStarted) {
|
|
198
|
+
return Effect.succeed(
|
|
199
|
+
StepOutcome.Continue({
|
|
200
|
+
error: input.error,
|
|
201
|
+
...modelTurnResult(input.collected)
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return Effect.fail(input.error)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const attemptModelTurn = <E2 = never, R2 = never>(
|
|
210
|
+
config: ModelTurnConfig,
|
|
211
|
+
options?: {
|
|
212
|
+
readonly onEvent?: (event: AgentEvent) => Effect.Effect<void, E2, R2>
|
|
213
|
+
readonly initialUsage?: AgentUsage
|
|
214
|
+
readonly overflowCompactionAttempt?: number
|
|
215
|
+
readonly compact?: (
|
|
216
|
+
messages: ReadonlyArray<AgentMessage>
|
|
217
|
+
) => Effect.Effect<OverflowCompactionResult, E2, R2>
|
|
218
|
+
}
|
|
219
|
+
): Effect.Effect<
|
|
220
|
+
ModelTurnOutcome,
|
|
221
|
+
AgentLoopError | E2,
|
|
222
|
+
ContextTransformer | LLMProvider | LoopConfig | R2
|
|
223
|
+
> =>
|
|
224
|
+
Effect.gen(function* () {
|
|
225
|
+
const overflowCompactionAttempt = options?.overflowCompactionAttempt ?? 0
|
|
226
|
+
|
|
227
|
+
if (!isOverflowCompactionAttemptCount(overflowCompactionAttempt)) {
|
|
228
|
+
return yield* Effect.fail(invalidOverflowCompactionAttemptError())
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const outcome = yield* collectModelTurnAttempt(runModelTurn(config), {
|
|
232
|
+
onEvent: options?.onEvent,
|
|
233
|
+
initialUsage: options?.initialUsage
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return yield* Match.value(outcome).pipe(
|
|
237
|
+
Match.tag('Collected', current => Effect.succeed(completedOutcome(current.collection))),
|
|
238
|
+
Match.tag('SinkFailed', current => Effect.fail(current.error)),
|
|
239
|
+
Match.tag('StreamFailed', current => {
|
|
240
|
+
if (!isAgentLoopError(current.error)) {
|
|
241
|
+
return Effect.fail(current.error)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return classifyModelTurnFailure({
|
|
245
|
+
error: current.error,
|
|
246
|
+
collected: {
|
|
247
|
+
assistantMessage:
|
|
248
|
+
current.collection.assistantMessage ?? current.collection.partialAssistantMessage,
|
|
249
|
+
toolCalls: current.collection.toolCalls,
|
|
250
|
+
usage: current.collection.usage,
|
|
251
|
+
stopReason: current.collection.stopReason
|
|
252
|
+
},
|
|
253
|
+
outputStarted: current.collection.outputStarted,
|
|
254
|
+
messages: config.messages,
|
|
255
|
+
overflowCompactionAttempt,
|
|
256
|
+
compact: options?.compact
|
|
257
|
+
})
|
|
258
|
+
}),
|
|
259
|
+
Match.exhaustive
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
type AttemptToolBatchFoldState = {
|
|
264
|
+
requests: ReadonlyArray<HitlRequest>
|
|
265
|
+
usage: AgentUsage
|
|
266
|
+
toolCalls: ModelTurnResult['toolCalls']
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const attemptToolBatch = <E2 = never, R2 = never>(
|
|
270
|
+
config: ToolBatchConfig,
|
|
271
|
+
options?: {
|
|
272
|
+
readonly onEvent?: (event: AgentEvent) => Effect.Effect<void, E2, R2>
|
|
273
|
+
}
|
|
274
|
+
): Effect.Effect<ToolBatchOutcome, AgentLoopError | E2, LoopConfig | ToolExecutor | R2> => {
|
|
275
|
+
const onEvent = options?.onEvent
|
|
276
|
+
|
|
277
|
+
return runToolBatch(config).pipe(
|
|
278
|
+
Stream.runFoldEffect(
|
|
279
|
+
(): AttemptToolBatchFoldState => ({
|
|
280
|
+
requests: [],
|
|
281
|
+
usage: config.usage ?? zeroAgentUsage,
|
|
282
|
+
toolCalls: []
|
|
283
|
+
}),
|
|
284
|
+
(acc, event) => {
|
|
285
|
+
const next = Predicate.isTagged(event, 'AgentAwaitingInput')
|
|
286
|
+
? { ...acc, requests: event.requests, usage: event.usage }
|
|
287
|
+
: Predicate.isTagged(event, 'UsageUpdate')
|
|
288
|
+
? { ...acc, usage: addAgentUsage(acc.usage, event.usage) }
|
|
289
|
+
: Predicate.isTagged(event, 'ToolExecutionCompleted') ||
|
|
290
|
+
Predicate.isTagged(event, 'ToolExecutionAccepted')
|
|
291
|
+
? { ...acc, toolCalls: [...acc.toolCalls, event.call] }
|
|
292
|
+
: acc
|
|
293
|
+
|
|
294
|
+
return onEvent === undefined ? Effect.succeed(next) : onEvent(event).pipe(Effect.as(next))
|
|
295
|
+
}
|
|
296
|
+
),
|
|
297
|
+
Effect.map((result): ToolBatchOutcome => {
|
|
298
|
+
if (result.requests.length === 0) {
|
|
299
|
+
const needsContinuation = result.toolCalls.length > 0
|
|
300
|
+
|
|
301
|
+
return StepOutcome.Completed({
|
|
302
|
+
needsContinuation,
|
|
303
|
+
assistantMessage: undefined,
|
|
304
|
+
toolCalls: result.toolCalls,
|
|
305
|
+
usage: result.usage,
|
|
306
|
+
stopReason: needsContinuation ? 'tool_use' : 'stop'
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return StepOutcome.AwaitingInput({ requests: result.requests, usage: result.usage })
|
|
311
|
+
})
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type HitlMatch =
|
|
316
|
+
| { readonly _tag: 'Match'; readonly requestId: string }
|
|
317
|
+
| { readonly _tag: 'Mismatch' }
|
|
318
|
+
|
|
319
|
+
export const HitlMatch = hitlMatch
|
|
320
|
+
|
|
321
|
+
const hitlResponseMatchesRequest = (response: HitlResponse, request: HitlRequest) =>
|
|
322
|
+
Match.value(response).pipe(
|
|
323
|
+
Match.tag(
|
|
324
|
+
'ToolApprovalResponse',
|
|
325
|
+
current =>
|
|
326
|
+
Predicate.isTagged(request, 'ToolApprovalRequest') &&
|
|
327
|
+
current.requestId === request.requestId &&
|
|
328
|
+
current.toolCallId === request.toolCallId
|
|
329
|
+
),
|
|
330
|
+
Match.tag(
|
|
331
|
+
'QuestionResponse',
|
|
332
|
+
current =>
|
|
333
|
+
Predicate.isTagged(request, 'QuestionRequest') &&
|
|
334
|
+
current.requestId === request.requestId &&
|
|
335
|
+
current.toolCallId === request.toolCallId
|
|
336
|
+
),
|
|
337
|
+
Match.tag(
|
|
338
|
+
'InputResponse',
|
|
339
|
+
current =>
|
|
340
|
+
Predicate.isTagged(request, 'InputRequest') &&
|
|
341
|
+
current.requestId === request.requestId &&
|
|
342
|
+
current.toolCallId === request.toolCallId
|
|
343
|
+
),
|
|
344
|
+
Match.tag(
|
|
345
|
+
'InteractionResponse',
|
|
346
|
+
current =>
|
|
347
|
+
Predicate.isTagged(request, 'InteractionRequest') &&
|
|
348
|
+
current.requestId === request.requestId &&
|
|
349
|
+
current.toolCallId === request.toolCallId
|
|
350
|
+
),
|
|
351
|
+
Match.exhaustive
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
export const matchHitlResponse = (
|
|
355
|
+
pending: ReadonlyArray<HitlRequest>,
|
|
356
|
+
response: HitlResponse
|
|
357
|
+
): HitlMatch => {
|
|
358
|
+
const matched = pending.find(request => hitlResponseMatchesRequest(response, request))
|
|
359
|
+
|
|
360
|
+
return matched === undefined
|
|
361
|
+
? HitlMatch.Mismatch()
|
|
362
|
+
: HitlMatch.Match({ requestId: matched.requestId })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export const resumeHitlIfMatched = <A, E, R>(input: {
|
|
366
|
+
readonly pending: ReadonlyArray<HitlRequest>
|
|
367
|
+
readonly response: HitlResponse
|
|
368
|
+
readonly resume: (requestId: string) => Effect.Effect<A, E, R>
|
|
369
|
+
}): Effect.Effect<A | Extract<HitlMatch, { readonly _tag: 'Mismatch' }>, E, R> => {
|
|
370
|
+
const matched = matchHitlResponse(input.pending, input.response)
|
|
371
|
+
|
|
372
|
+
return Match.value(matched).pipe(
|
|
373
|
+
Match.tag('Mismatch', current => Effect.succeed(current)),
|
|
374
|
+
Match.tag('Match', current => input.resume(current.requestId)),
|
|
375
|
+
Match.exhaustive
|
|
376
|
+
)
|
|
377
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Ref, Semaphore } from 'effect'
|
|
2
|
+
|
|
3
|
+
export type RunStoreApi = {
|
|
4
|
+
readonly claim: (runId: string) => Effect.Effect<void>
|
|
5
|
+
readonly release: (runId: string) => Effect.Effect<void>
|
|
6
|
+
readonly isClaimed: (runId: string) => Effect.Effect<boolean>
|
|
7
|
+
readonly claimed: Effect.Effect<ReadonlySet<string>>
|
|
8
|
+
readonly incrementResumeCount: (runId: string) => Effect.Effect<number>
|
|
9
|
+
readonly resumeCount: (runId: string) => Effect.Effect<number>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type SnapshotRunStoreOptions = {
|
|
13
|
+
readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>
|
|
14
|
+
readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DurableRunStoreSnapshot = {
|
|
18
|
+
readonly claimed: ReadonlyArray<string>
|
|
19
|
+
readonly resumes: ReadonlyArray<readonly [string, number]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type MemorySnapshot = {
|
|
23
|
+
readonly claimed: ReadonlySet<string>
|
|
24
|
+
readonly resumes: ReadonlyMap<string, number>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const emptySnapshot: DurableRunStoreSnapshot = {
|
|
28
|
+
claimed: [],
|
|
29
|
+
resumes: []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const snapshotFromMemory = (memory: MemorySnapshot): DurableRunStoreSnapshot => ({
|
|
33
|
+
claimed: [...memory.claimed],
|
|
34
|
+
resumes: [...memory.resumes.entries()]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const memoryFromSnapshot = (snapshot: DurableRunStoreSnapshot): MemorySnapshot => ({
|
|
38
|
+
claimed: new Set(snapshot.claimed),
|
|
39
|
+
resumes: new Map(snapshot.resumes)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export class RunStore extends Context.Service<RunStore, RunStoreApi>()(
|
|
43
|
+
'@yolk-sdk/harness/RunStore'
|
|
44
|
+
) {
|
|
45
|
+
/**
|
|
46
|
+
* Canonical owning layer for snapshot-backed claims. Each call builds a fresh layer so
|
|
47
|
+
* composed harnesses never share `Ref` state or the snapshot lock across factory calls.
|
|
48
|
+
*/
|
|
49
|
+
static snapshotLayer = (options: SnapshotRunStoreOptions): Layer.Layer<RunStore> =>
|
|
50
|
+
Layer.effect(
|
|
51
|
+
this,
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const loaded = yield* options.load
|
|
54
|
+
const memory = yield* Ref.make(memoryFromSnapshot(loaded ?? emptySnapshot))
|
|
55
|
+
const lock = yield* Semaphore.make(1)
|
|
56
|
+
|
|
57
|
+
const mutate = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
58
|
+
Effect.uninterruptibleMask(restore =>
|
|
59
|
+
restore(lock.take(1)).pipe(
|
|
60
|
+
Effect.flatMap(() => effect.pipe(Effect.ensuring(lock.release(1))))
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const commit = (next: MemorySnapshot) =>
|
|
65
|
+
options.save(snapshotFromMemory(next)).pipe(Effect.flatMap(() => Ref.set(memory, next)))
|
|
66
|
+
|
|
67
|
+
return RunStore.of({
|
|
68
|
+
claim: runId =>
|
|
69
|
+
mutate(
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const current = yield* Ref.get(memory)
|
|
72
|
+
yield* commit({
|
|
73
|
+
claimed: new Set(current.claimed).add(runId),
|
|
74
|
+
resumes: current.resumes
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
),
|
|
78
|
+
release: runId =>
|
|
79
|
+
mutate(
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const current = yield* Ref.get(memory)
|
|
82
|
+
const claimed = new Set(current.claimed)
|
|
83
|
+
claimed.delete(runId)
|
|
84
|
+
const resumes = new Map(current.resumes)
|
|
85
|
+
resumes.delete(runId)
|
|
86
|
+
yield* commit({ claimed, resumes })
|
|
87
|
+
})
|
|
88
|
+
),
|
|
89
|
+
isClaimed: runId =>
|
|
90
|
+
Ref.get(memory).pipe(Effect.map(current => current.claimed.has(runId))),
|
|
91
|
+
claimed: Ref.get(memory).pipe(Effect.map(current => new Set(current.claimed))),
|
|
92
|
+
incrementResumeCount: runId =>
|
|
93
|
+
mutate(
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const current = yield* Ref.get(memory)
|
|
96
|
+
const nextCount = (current.resumes.get(runId) ?? 0) + 1
|
|
97
|
+
const resumes = new Map(current.resumes)
|
|
98
|
+
resumes.set(runId, nextCount)
|
|
99
|
+
yield* commit({ claimed: current.claimed, resumes })
|
|
100
|
+
|
|
101
|
+
return nextCount
|
|
102
|
+
})
|
|
103
|
+
),
|
|
104
|
+
resumeCount: runId =>
|
|
105
|
+
Ref.get(memory).pipe(Effect.map(current => current.resumes.get(runId) ?? 0))
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Canonical owning layer for process-local claims. Each call builds a fresh layer so
|
|
112
|
+
* composed harnesses never share `Ref` state across factory calls.
|
|
113
|
+
*/
|
|
114
|
+
static inMemoryLayer = (): Layer.Layer<RunStore> =>
|
|
115
|
+
Layer.effect(
|
|
116
|
+
this,
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const claimed = yield* Ref.make(new Set<string>())
|
|
119
|
+
const resumes = yield* Ref.make(new Map<string, number>())
|
|
120
|
+
|
|
121
|
+
return RunStore.of({
|
|
122
|
+
claim: runId => Ref.update(claimed, current => new Set(current).add(runId)),
|
|
123
|
+
release: runId =>
|
|
124
|
+
Effect.zip(
|
|
125
|
+
Ref.update(claimed, current => {
|
|
126
|
+
const next = new Set(current)
|
|
127
|
+
next.delete(runId)
|
|
128
|
+
|
|
129
|
+
return next
|
|
130
|
+
}),
|
|
131
|
+
Ref.update(resumes, current => {
|
|
132
|
+
const next = new Map(current)
|
|
133
|
+
next.delete(runId)
|
|
134
|
+
|
|
135
|
+
return next
|
|
136
|
+
})
|
|
137
|
+
).pipe(Effect.asVoid),
|
|
138
|
+
isClaimed: runId => Ref.get(claimed).pipe(Effect.map(current => current.has(runId))),
|
|
139
|
+
claimed: Ref.get(claimed).pipe(Effect.map(current => new Set(current))),
|
|
140
|
+
incrementResumeCount: runId =>
|
|
141
|
+
Ref.modify(resumes, current => {
|
|
142
|
+
const nextCount = (current.get(runId) ?? 0) + 1
|
|
143
|
+
const next = new Map(current)
|
|
144
|
+
next.set(runId, nextCount)
|
|
145
|
+
|
|
146
|
+
return [nextCount, next] as const
|
|
147
|
+
}),
|
|
148
|
+
resumeCount: runId =>
|
|
149
|
+
Ref.get(resumes).pipe(Effect.map(current => current.get(runId) ?? 0))
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Backward-compatible delegation to {@link RunStore.snapshotLayer}. */
|
|
156
|
+
export const makeSnapshotRunStoreLayer = (
|
|
157
|
+
options: SnapshotRunStoreOptions
|
|
158
|
+
): Layer.Layer<RunStore> => RunStore.snapshotLayer(options)
|
|
159
|
+
|
|
160
|
+
/** Backward-compatible delegation to {@link RunStore.inMemoryLayer}. */
|
|
161
|
+
export const makeInMemoryRunStoreLayer = (): Layer.Layer<RunStore> => RunStore.inMemoryLayer()
|