tardie 0.22.2 → 0.22.3-rc.265

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tardie",
3
- "version": "0.22.2",
3
+ "version": "0.22.3-rc.265",
4
4
  "license": "MIT",
5
5
  "author": "Clavia, Inc.",
6
6
  "description": "A durable agent harness. State is a pure function of the log.",
@@ -16,7 +16,7 @@
16
16
  "access": "public"
17
17
  },
18
18
  "tardigrade": {
19
- "sourceTree": "51defd3cb83af555260546ac69a04a069a47c68f"
19
+ "sourceTree": "c77212bc7abbcc6391e5c56d162d2f27830f71c8"
20
20
  },
21
21
  "files": [
22
22
  "src",
@@ -256,7 +256,7 @@ const childClaimOf = (
256
256
  ...(recorded.placement === undefined ? {} : { placement: recorded.placement })
257
257
  }
258
258
  const target = recorded?.address ?? (yield* allocateChildThread({
259
- parent: source, child: childKeyOf(JSON.stringify([parentRunId, callId]))
259
+ parent: source, child: childKeyOf("unnamed"), key: JSON.stringify([parentRunId, callId])
260
260
  }))
261
261
  // clash rejects a derived address already claimed by another recorded child (agents.test.ts, "a derived address that names another child dies rather than delivering").
262
262
  if (recorded === undefined) {
package/src/bun/host.ts CHANGED
@@ -295,7 +295,7 @@ export const createBunHost = async <R = never>(options: BunHostOptions<R>): Prom
295
295
  `.pipe(
296
296
  Effect.map((rows) => ({
297
297
  cursor: Number(rows.at(-1)?.seq ?? 0),
298
- threads: actorThreadsOf(rows.map((row) => JSON.parse(row.event) as Event)).filter((record) => record.state !== "allocated")
298
+ threads: actorThreadsOf(rows.map((row) => JSON.parse(row.event) as Event))
299
299
  })),
300
300
  Effect.orDie
301
301
  ))
@@ -342,7 +342,7 @@ export const createBunHost = async <R = never>(options: BunHostOptions<R>): Prom
342
342
  }),
343
343
  at
344
344
  } satisfies ThreadRequested)
345
- await appendActorEvent({ type: "ThreadRegistered", thread, at } satisfies ThreadRegistered)
345
+ await appendActorEvent({ type: "ThreadRegistered", thread, ...(lineage?.placement === undefined ? {} : { placement: lineage.placement }), at } satisfies ThreadRegistered)
346
346
  }
347
347
  const threads = (): Promise<ReadonlyArray<string>> => directoryRuntime.runPromise(
348
348
  directorySql<{ thread: string }>`SELECT thread FROM thread_directory ORDER BY thread`.pipe(Effect.map((rows) => rows.map((row) => row.thread)), Effect.orDie)
@@ -44,17 +44,18 @@ const actorSupervisorOf = (
44
44
  key: registeredKeyOf(event.thread),
45
45
  input: event,
46
46
  act: (request) => Effect.gen(function* () {
47
- yield* Effect.promise(async () => {
47
+ const registration = yield* Effect.promise(async () => {
48
48
  const stub = env.THREADS.getByName(threadObjectNameOf(identity.actor, identity.instance, request.thread))
49
49
  if (request.parentThread === undefined) {
50
- if (!(await stub.exists(identity.actor, identity.instance, request.thread))) {
51
- throw new Error(`root thread ${JSON.stringify(request.thread)} has no durable host`)
52
- }
53
- } else {
54
- await stub.commitCreation()
50
+ await stub.init(identity.actor, identity.instance, request.thread)
51
+ await stub.initializeRoot()
52
+ return {}
55
53
  }
54
+ const created = await stub.commitCreation()
55
+ return created === undefined ? undefined : created.placement === undefined ? {} : { placement: created.placement }
56
56
  })
57
- return [{ type: "ThreadRegistered", thread: request.thread, at: yield* Clock.currentTimeMillis }]
57
+ if (registration === undefined) return []
58
+ return [{ type: "ThreadRegistered", thread: request.thread, ...registration, at: yield* Clock.currentTimeMillis }]
58
59
  })
59
60
  })]
60
61
  })
@@ -220,12 +221,15 @@ export class ActorDO extends DurableObject<Env> {
220
221
  WHERE json_extract(event, '$.type') = 'ThreadRequested' AND json_extract(event, '$.thread') = ${target.thread}`.pipe(
221
222
  Effect.map((rows) => rows.length > 0 && (!existingRoot || rows.some((row) => row.parent !== null))), Effect.orDie
222
223
  ))
223
- return Effect.runPromise(allocateThread(request).pipe(Effect.provideService(
224
+ const target = await Effect.runPromise(allocateThread(request).pipe(Effect.provideService(
224
225
  ThreadAllocator, mountedActor?.threadAllocator ?? registeredThreadAllocator({
225
226
  get: (key) => Effect.promise(() => this.database.runPromise(store.get(key))),
226
227
  claim: (key, target, existingRoot, request) => Effect.promise(() => this.database.runPromise(store.claim(key, target, existingRoot, request)))
227
228
  }, mountedActor?.allocation)
228
229
  )))
230
+ const at = armAt(await this.ctx.storage.getAlarm(), Date.now(), this.alarmPolicy.recoveryDelayMillis)
231
+ if (at !== null) await this.ctx.storage.setAlarm(at)
232
+ return target
229
233
  }
230
234
 
231
235
  async initializeRootThread(thread: string): Promise<void> {
@@ -261,7 +265,7 @@ export class ActorDO extends DurableObject<Env> {
261
265
  if (existing !== undefined && (
262
266
  existing.parentThread !== lineage.parent.thread ||
263
267
  Number(existing.depth) !== lineage.depth ||
264
- (existing.state !== "allocated" && existing.placement !== placement)
268
+ ((existing.state === "registered" || existing.placement !== undefined) && (existing.placement ?? null) !== placement)
265
269
  )) {
266
270
  throw new Error("a child thread already has different lineage")
267
271
  }
@@ -1,3 +1,4 @@
1
+ import { threadCreatedOf, type ThreadCreated } from "tardie/core/interaction/relations"
1
2
  import { cloudflareRpcTransport } from "./transport/rpc"
2
3
  import { DurableObject } from "cloudflare:workers"
3
4
  import { Effect, Layer, Schema } from "effect"
@@ -313,12 +314,16 @@ export class ThreadDO extends DurableObject<Env> {
313
314
  await this.ctx.storage.sync()
314
315
  }
315
316
 
316
- async commitCreation(): Promise<void> {
317
+ async commitCreation(): Promise<ThreadCreated | undefined> {
318
+ if (!this.initialized()) return undefined
317
319
  const host = await this.host()
320
+ const created = threadCreatedOf(await host.read())
321
+ if (created === undefined) return undefined
318
322
  await this.arm()
319
323
  await this.commitTurn()
320
324
  host.publishStaged()
321
325
  this.kick(host)
326
+ return created
322
327
  }
323
328
 
324
329
  async deliver(envelope: ActorEnvelope): Promise<void> {
@@ -3,6 +3,7 @@ import type { ChildPlacement } from "../interaction/relations"
3
3
 
4
4
  export interface ThreadRequested extends Event {
5
5
  readonly type: "ThreadRequested"
6
+ readonly allocationKey?: string
6
7
  readonly thread: string
7
8
  readonly parentThread?: string
8
9
  readonly depth: number
@@ -10,22 +11,14 @@ export interface ThreadRequested extends Event {
10
11
  readonly at: number
11
12
  }
12
13
 
13
- export interface ThreadAllocated extends Event {
14
- readonly type: "ThreadAllocated"
15
- readonly thread: string
16
- readonly allocationKey: string
17
- readonly parentThread?: string
18
- readonly depth: number
19
- readonly at: number
20
- }
21
-
22
14
  export interface ThreadRegistered extends Event {
23
15
  readonly type: "ThreadRegistered"
16
+ readonly placement?: ChildPlacement
24
17
  readonly thread: string
25
18
  readonly at: number
26
19
  }
27
20
 
28
- export type ActorEvent = ThreadAllocated | ThreadRequested | ThreadRegistered
21
+ export type ActorEvent = ThreadRequested | ThreadRegistered
29
22
 
30
23
  export interface ActorThreadRecord {
31
24
  readonly allocationKey?: string
@@ -33,37 +26,28 @@ export interface ActorThreadRecord {
33
26
  readonly parentThread?: string
34
27
  readonly depth: number
35
28
  readonly placement?: ChildPlacement
36
- readonly state: "allocated" | "requested" | "registered"
29
+ readonly state: "requested" | "registered"
37
30
  }
38
31
 
39
32
  export const actorEventKeyOf = (event: Event): string | undefined => {
40
- if (event.type === "ThreadAllocated" && typeof event.thread === "string") return `thread:allocated:${event.thread}`
41
33
  if (event.type === "ThreadRequested" && typeof event.thread === "string") return `thread:requested:${event.thread}`
42
34
  if (event.type === "ThreadRegistered" && typeof event.thread === "string") return `thread:registered:${event.thread}`
43
35
  return undefined
44
36
  }
45
37
 
46
38
  export const actorEventsOf = (events: ReadonlyArray<Event>): ReadonlyArray<ActorEvent> =>
47
- events.filter((event): event is ActorEvent => {
48
- if (typeof event.thread !== "string") return false
49
- return event.type === "ThreadAllocated" || event.type === "ThreadRequested" || event.type === "ThreadRegistered"
39
+ events.flatMap((event): ReadonlyArray<ActorEvent> => {
40
+ if (typeof event.thread !== "string") return []
41
+ return event.type === "ThreadRequested" || event.type === "ThreadRegistered" ? [event as ActorEvent] : []
50
42
  })
51
43
 
52
44
  export const actorThreadsOf = (events: ReadonlyArray<Event>): ReadonlyArray<ActorThreadRecord> => {
53
45
  const entries = new Map<string, ActorThreadRecord>()
54
46
  for (const event of actorEventsOf(events)) {
55
47
  const current = entries.get(event.thread)
56
- if (event.type === "ThreadAllocated") {
57
- entries.set(event.thread, current === undefined ? {
58
- thread: event.thread, allocationKey: event.allocationKey, depth: event.depth,
59
- ...(event.parentThread === undefined ? {} : { parentThread: event.parentThread }),
60
- state: "allocated"
61
- } : { ...current, allocationKey: event.allocationKey })
62
- continue
63
- }
64
48
  if (event.type === "ThreadRequested") {
65
49
  entries.set(event.thread, {
66
- ...(current?.allocationKey === undefined ? {} : { allocationKey: current.allocationKey }),
50
+ ...(event.allocationKey === undefined ? {} : { allocationKey: event.allocationKey }),
67
51
  thread: event.thread,
68
52
  ...(event.parentThread === undefined ? {} : { parentThread: event.parentThread }),
69
53
  depth: event.depth,
@@ -73,7 +57,7 @@ export const actorThreadsOf = (events: ReadonlyArray<Event>): ReadonlyArray<Acto
73
57
  continue
74
58
  }
75
59
  if (current === undefined) throw new Error(`thread ${JSON.stringify(event.thread)} has no request`)
76
- entries.set(event.thread, { ...current, state: "registered" })
60
+ entries.set(event.thread, { ...current, ...(event.placement === undefined ? {} : { placement: event.placement }), state: "registered" })
77
61
  }
78
62
  return [...entries.values()]
79
63
  }
@@ -12,7 +12,7 @@ export const sqlThreadDirectory = (
12
12
  occupied: (target: ThreadCoordinate, existingRoot: boolean) => Effect.Effect<boolean>
13
13
  ): ThreadAllocationStore => {
14
14
  const get = (key: string) => sql<{ thread: string }>`SELECT json_extract(event, '$.thread') AS thread
15
- FROM ${sql(table)} WHERE json_extract(event, '$.type') = 'ThreadAllocated'
15
+ FROM ${sql(table)} WHERE json_extract(event, '$.type') = 'ThreadRequested'
16
16
  AND json_extract(event, '$.allocationKey') = ${key}`.pipe(
17
17
  Effect.map((rows) => rows[0]?.thread), Effect.orDie
18
18
  )
@@ -1,7 +1,7 @@
1
1
  import { Clock, Effect, Schema } from "effect"
2
2
  import { ThreadCoordinate, threadIdOf } from "tardie/core/actor/coordinate"
3
3
  import { allocateThread, ThreadAllocator, type ThreadAllocation } from "tardie/core/actor/allocation"
4
- import { actorThreadsOf, type ThreadAllocated } from "tardie/core/actor/events"
4
+ import { actorThreadsOf, type ThreadRequested } from "tardie/core/actor/events"
5
5
  import type { Event } from "tardie/core/event"
6
6
 
7
7
  // instanceThreadAllocator rejects assignments outside the owning actor instance.
@@ -68,16 +68,17 @@ export interface ThreadAllocationStore {
68
68
  // threadAllocationRecord keeps allocation identity in the actor's thread record (allocation.test.ts).
69
69
  export const threadAllocationRecord = (
70
70
  events: ReadonlyArray<Event>, request: ThreadAllocation, target: ThreadCoordinate, existingRoot: boolean, at: number
71
- ): { readonly thread: string; readonly event?: ThreadAllocated } | undefined => {
71
+ ): { readonly thread: string; readonly event?: ThreadRequested } | undefined => {
72
72
  const key = threadAllocationKey(request)
73
73
  const records = actorThreadsOf(events)
74
74
  const assigned = records.find((record) => record.allocationKey === key)
75
75
  if (assigned !== undefined) return { thread: assigned.thread }
76
76
  const current = records.find((record) => record.thread === target.thread)
77
77
  if (current !== undefined && (current.allocationKey !== undefined || !existingRoot || current.parentThread !== undefined)) return undefined
78
+ if (current !== undefined) return { thread: current.thread }
78
79
  const parent = request.kind === "child" ? request.parent.thread : undefined
79
80
  return { thread: target.thread, event: {
80
- type: "ThreadAllocated", thread: target.thread, allocationKey: key,
81
+ type: "ThreadRequested", thread: target.thread, allocationKey: key,
81
82
  ...(parent === undefined ? {} : { parentThread: parent }),
82
83
  depth: parent === undefined ? 0 : (records.find((record) => record.thread === parent)?.depth ?? 0) + 1,
83
84
  at
@@ -103,13 +104,19 @@ export const registeredThreadAllocator = (
103
104
  const key = threadAllocationKey(request)
104
105
  const recorded = yield* store.get(key)
105
106
  if (recorded !== undefined) return { ...parent, thread: recorded }
107
+ if (request.key === undefined) {
108
+ const name = threadIdOf(request.kind === "root" ? parent.thread : request.child)
109
+ const thread = request.kind === "child" && name === parent.thread ? undefined
110
+ : yield* store.claim(key, { ...parent, thread: name }, request.kind === "root", request)
111
+ if (thread !== undefined) return { ...parent, thread }
112
+ return yield* Effect.die(new Error(`thread name ${JSON.stringify(name)} is already taken in actor instance ${JSON.stringify([parent.actor, parent.instance])}`))
113
+ }
106
114
  const attempts = policy.maxAttempts ?? DEFAULT_THREAD_ALLOCATION_ATTEMPTS
107
115
  if (!Number.isSafeInteger(attempts) || attempts < 1) throw new Error("allocation attempts must be a positive integer")
108
- const namedRoot = request.kind === "root" && request.key === undefined
109
116
  for (let attempt = 0; attempt < attempts; attempt++) {
110
- const candidate = threadIdOf(namedRoot && attempt === 0 ? parent.thread : (policy.generate ?? (() => threadSlug(policy)))())
117
+ const candidate = threadIdOf((policy.generate ?? (() => threadSlug(policy)))())
111
118
  if (request.kind === "child" && candidate === parent.thread) continue
112
- const thread = yield* store.claim(key, { ...parent, thread: candidate }, namedRoot && candidate === parent.thread, request)
119
+ const thread = yield* store.claim(key, { ...parent, thread: candidate }, false, request)
113
120
  if (thread !== undefined) return { ...parent, thread }
114
121
  }
115
122
  return yield* Effect.die(new Error(`thread allocation exhausted ${attempts} collision attempts`))