@arcforge/cognet 2.0.111 → 2.0.113

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 (2) hide show
  1. package/package.json +3 -3
  2. package/src/host.ts +57 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforge/cognet",
3
- "version": "2.0.111",
3
+ "version": "2.0.113",
4
4
  "description": "A cognitive engine",
5
5
  "type": "module",
6
6
  "private": false,
@@ -18,8 +18,8 @@
18
18
  "deploy": "echo \"This package is published ONLY by apps/tui/scripts/release.ts, which pins workspace:* deps to concrete versions first. Publishing it directly ships an unresolvable dependency.\" && exit 1"
19
19
  },
20
20
  "dependencies": {
21
- "@arcforge/err": "2.0.111",
22
- "@arcforge/types": "2.0.111",
21
+ "@arcforge/err": "2.0.113",
22
+ "@arcforge/types": "2.0.113",
23
23
  "hookable": "^5.5.3"
24
24
  },
25
25
  "devDependencies": {
package/src/host.ts CHANGED
@@ -47,7 +47,6 @@ type LoopBody = (ctx: LoopCtx) => Promise<void>
47
47
 
48
48
  let registered: LoopBody | null = null
49
49
  let boundKernel: KernelAbi | null = null
50
- let currentClock: ReturnType<typeof Clock> | null = null
51
50
  let loaded = false
52
51
 
53
52
  type CognetAmbientScope = {
@@ -55,6 +54,20 @@ type CognetAmbientScope = {
55
54
  loop(body: LoopBody): void
56
55
  phase<T>(name: string, fn: () => Promise<T>): Promise<T>
57
56
  system<T>(name: string, fn: () => Promise<T>): Promise<T>
57
+ /**
58
+ * The clock of the wake this async context belongs to.
59
+ *
60
+ * Carried in the scope rather than a module-level `currentClock`, because
61
+ * that single mutable assumed one wake existed at a time. A continuous
62
+ * cognet ticks whether or not the previous wake finished, so two bodies
63
+ * overlap routinely: the second assigned `currentClock`, the first's
64
+ * phase() then timed against the wrong clock, and whichever finished
65
+ * first set it null — leaving the other to throw mid-flight. Every
66
+ * symptom silent, and all of it invisible until a wake outlived a tick.
67
+ *
68
+ * Null outside a wake (load, shutdown), where phase() is illegal anyway.
69
+ */
70
+ clock: ReturnType<typeof Clock> | null
58
71
  }
59
72
 
60
73
  // Every compiled cognet carries an inlined copy of this module, but all of
@@ -84,8 +97,9 @@ function kernelOrThrow(): KernelAbi {
84
97
  }
85
98
 
86
99
  function clockOrThrow(): ReturnType<typeof Clock> {
87
- if (!currentClock) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "phase()/system() are wake-scoped — call them inside the loop body" })
88
- return currentClock
100
+ const clock = ambientStorage.getStore()?.clock
101
+ if (!clock) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "phase()/system() are wake-scoped — call them inside the loop body" })
102
+ return clock
89
103
  }
90
104
 
91
105
  // ── ambient globals ──────────────────────────────────────────────────────────
@@ -136,15 +150,35 @@ const localKernel = {
136
150
  get: (key: never) => kernelOrThrow().store.get(key),
137
151
  set: (key: never, value: never) => kernelOrThrow().store.set(key, value),
138
152
  },
153
+ wake: () => kernelOrThrow().wake(),
154
+ clock: () => kernelOrThrow().clock(),
155
+ // A getter, not a captured value: the bound kernel arrives at load(), and
156
+ // capturing this at module scope would freeze an empty map from before
157
+ // the brain was given anything.
158
+ get models() { return kernelOrThrow().models },
139
159
  } satisfies KernelAbi
140
160
 
141
- const localScope: CognetAmbientScope = {
142
- kernel: localKernel,
143
- loop: registerLoop,
144
- phase: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runPhase(name, fn),
145
- system: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runSystem(name, fn),
161
+ /**
162
+ * The ambient scope for one execution. Everything in it is shared except the
163
+ * clock, which belongs to the wake that created it — see CognetAmbientScope.
164
+ *
165
+ * A function rather than a singleton: concurrent wakes each need their own
166
+ * store, and `ambientStorage.run(sharedObject, ...)` would have given them
167
+ * one object to fight over.
168
+ */
169
+ function scopeFor(clock: ReturnType<typeof Clock> | null): CognetAmbientScope {
170
+ return {
171
+ kernel: localKernel,
172
+ loop: registerLoop,
173
+ phase: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runPhase(name, fn),
174
+ system: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runSystem(name, fn),
175
+ clock,
176
+ }
146
177
  }
147
178
 
179
+ /** Load, boot and shutdown run outside any wake, so they carry no clock. */
180
+ const localScope: CognetAmbientScope = scopeFor(null)
181
+
148
182
  // Stable process-wide facades. They never capture one cognet instance; every
149
183
  // operation resolves the current async scope at the moment it is performed.
150
184
  globals.loop = (body: LoopBody): void => ambientOrThrow().loop(body)
@@ -160,6 +194,13 @@ globals.kernel = {
160
194
  get: (key: never) => ambientOrThrow().kernel.store.get(key),
161
195
  set: (key: never, value: never) => ambientOrThrow().kernel.store.set(key, value),
162
196
  },
197
+ // Resolved through kernelOrThrow(), not ambientOrThrow(): a plugin's
198
+ // clock lives in a setInterval registered during boot, and that callback
199
+ // fires outside every ambient scope. The bound kernel is module state
200
+ // that outlives each wake, which is exactly what a driver needs.
201
+ wake: () => kernelOrThrow().wake(),
202
+ clock: () => kernelOrThrow().clock(),
203
+ get models() { return kernelOrThrow().models },
163
204
  } satisfies KernelAbi
164
205
 
165
206
  globals.phase = <T>(name: string, fn: () => Promise<T>) => ambientOrThrow().phase(name, fn)
@@ -202,16 +243,20 @@ export function CognetHost(config: CognetConfig, main: () => Promise<void>): Cog
202
243
  },
203
244
 
204
245
  async wake(wake) {
205
- return ambientStorage.run(localScope, async () => {
206
246
  const body = registered
207
247
  if (!body) throw err("COGNET_NO_LOOP", { detail: `${config.name} woken before load()`, context: { name: config.name } })
208
248
 
249
+ // Built BEFORE entering the scope, because the scope carries it.
250
+ // Two overlapping wakes get two clocks and two stores, so each
251
+ // body's phase() resolves its own no matter how the awaits
252
+ // interleave. `emit` is bound through kernelOrThrow(), which
253
+ // reads module state that outlives every wake — safe to close
254
+ // over here.
209
255
  const clock = Clock({ emit: (type, data) => kernelOrThrow().emit(type, data), signal: wake.signal })
210
- currentClock = clock
211
256
 
212
- await hooks.callHook("wake", wake)
257
+ return ambientStorage.run(scopeFor(clock), async () => {
258
+ await hooks.callHook("wake", wake)
213
259
 
214
- try {
215
260
  let stopped = false
216
261
  const ctx: LoopCtx = { ...wake, stop: () => { stopped = true } }
217
262
  const maxTicks = config.maxTicksPerWake ?? Infinity
@@ -228,9 +273,6 @@ export function CognetHost(config: CognetConfig, main: () => Promise<void>): Cog
228
273
  await body(ctx)
229
274
  })
230
275
  }
231
- } finally {
232
- currentClock = null
233
- }
234
276
  })
235
277
  },
236
278