@resonatehq/sdk 0.10.0 → 0.10.2

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 (48) hide show
  1. package/README.md +2 -1
  2. package/dist/context.d.ts +10 -5
  3. package/dist/context.d.ts.map +1 -1
  4. package/dist/context.js +14 -7
  5. package/dist/context.js.map +1 -1
  6. package/dist/core.d.ts.map +1 -1
  7. package/dist/core.js +10 -10
  8. package/dist/core.js.map +1 -1
  9. package/dist/coroutine.d.ts.map +1 -1
  10. package/dist/coroutine.js +1 -0
  11. package/dist/coroutine.js.map +1 -1
  12. package/dist/decorator.d.ts +1 -0
  13. package/dist/decorator.d.ts.map +1 -1
  14. package/dist/decorator.js +1 -0
  15. package/dist/decorator.js.map +1 -1
  16. package/dist/index.d.ts +4 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/network/http.d.ts +4 -0
  20. package/dist/network/http.d.ts.map +1 -1
  21. package/dist/network/http.js +12 -1
  22. package/dist/network/http.js.map +1 -1
  23. package/dist/network/local.d.ts +20 -11
  24. package/dist/network/local.d.ts.map +1 -1
  25. package/dist/network/local.js +256 -139
  26. package/dist/network/local.js.map +1 -1
  27. package/dist/network/network.d.ts +1 -0
  28. package/dist/network/network.d.ts.map +1 -1
  29. package/dist/network/types.d.ts +1 -0
  30. package/dist/network/types.d.ts.map +1 -1
  31. package/dist/network/types.js +2 -1
  32. package/dist/network/types.js.map +1 -1
  33. package/dist/options.d.ts +5 -2
  34. package/dist/options.d.ts.map +1 -1
  35. package/dist/options.js +5 -3
  36. package/dist/options.js.map +1 -1
  37. package/dist/promises.d.ts +14 -1
  38. package/dist/promises.d.ts.map +1 -1
  39. package/dist/promises.js +9 -0
  40. package/dist/promises.js.map +1 -1
  41. package/dist/resonate.d.ts.map +1 -1
  42. package/dist/resonate.js +1 -14
  43. package/dist/resonate.js.map +1 -1
  44. package/dist/util.d.ts +5 -1
  45. package/dist/util.d.ts.map +1 -1
  46. package/dist/util.js +56 -11
  47. package/dist/util.js.map +1 -1
  48. package/package.json +1 -1
@@ -6,9 +6,6 @@ import { isResponse } from "./types.js";
6
6
  // CONSTANTS
7
7
  // =============================================================================
8
8
  const PENDING_RETRY_TTL = 30000;
9
- // =============================================================================
10
- // SERVER
11
- // =============================================================================
12
9
  export class Server {
13
10
  promises = new Map();
14
11
  tasks = new Map();
@@ -17,6 +14,15 @@ export class Server {
17
14
  tTimeouts = [];
18
15
  sTimeouts = [];
19
16
  outgoing = [];
17
+ // When "eager", every action handler converges physical state to logical
18
+ // state by running tryEagerTimeout for the relevant id(s) before the
19
+ // handler body. When "none", convergence happens only via debug.tick (or
20
+ // an equivalent background mechanism) — read paths must rely on
21
+ // projection to return the logical state.
22
+ timeoutMode;
23
+ constructor(opts = {}) {
24
+ this.timeoutMode = opts.timeoutMode ?? "eager";
25
+ }
20
26
  apply(now, req) {
21
27
  const changes = [];
22
28
  const error = this.validate(req);
@@ -26,28 +32,28 @@ export class Server {
26
32
  let result;
27
33
  switch (req.kind) {
28
34
  case "promise.get": {
29
- changes.push(...this.tryAutoTimeout(now, req.data.id));
35
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
30
36
  result = this.promiseGet(now, req);
31
37
  break;
32
38
  }
33
39
  case "promise.create": {
34
- changes.push(...this.tryAutoTimeout(now, req.data.id));
40
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
35
41
  result = this.promiseCreate(now, req);
36
42
  break;
37
43
  }
38
44
  case "promise.settle": {
39
- changes.push(...this.tryAutoTimeout(now, req.data.id));
45
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
40
46
  result = this.promiseSettle(now, req);
41
47
  break;
42
48
  }
43
49
  case "promise.register_callback": {
44
- changes.push(...this.tryAutoTimeout(now, req.data.awaited));
45
- changes.push(...this.tryAutoTimeout(now, req.data.awaiter));
50
+ changes.push(...this.tryEagerTimeout(now, req.data.awaited));
51
+ changes.push(...this.tryEagerTimeout(now, req.data.awaiter));
46
52
  result = this.promiseRegisterCallback(now, req);
47
53
  break;
48
54
  }
49
55
  case "promise.register_listener": {
50
- changes.push(...this.tryAutoTimeout(now, req.data.awaited));
56
+ changes.push(...this.tryEagerTimeout(now, req.data.awaited));
51
57
  result = this.promiseRegisterListener(now, req);
52
58
  break;
53
59
  }
@@ -56,51 +62,51 @@ export class Server {
56
62
  break;
57
63
  }
58
64
  case "task.get": {
59
- changes.push(...this.tryAutoTimeout(now, req.data.id));
65
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
60
66
  result = this.taskGet(now, req);
61
67
  break;
62
68
  }
63
69
  case "task.create": {
64
- changes.push(...this.tryAutoTimeout(now, req.data.action.data.id));
70
+ changes.push(...this.tryEagerTimeout(now, req.data.action.data.id));
65
71
  result = this.taskCreate(now, req);
66
72
  break;
67
73
  }
68
74
  case "task.acquire": {
69
- changes.push(...this.tryAutoTimeout(now, req.data.id));
75
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
70
76
  result = this.taskAcquire(now, req);
71
77
  break;
72
78
  }
73
79
  case "task.release": {
74
- changes.push(...this.tryAutoTimeout(now, req.data.id));
80
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
75
81
  result = this.taskRelease(now, req);
76
82
  break;
77
83
  }
78
84
  case "task.fulfill": {
79
- changes.push(...this.tryAutoTimeout(now, req.data.id));
85
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
80
86
  result = this.taskFulfill(now, req);
81
87
  break;
82
88
  }
83
89
  case "task.suspend": {
84
- changes.push(...this.tryAutoTimeout(now, req.data.id));
90
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
85
91
  for (const action of req.data.actions) {
86
- changes.push(...this.tryAutoTimeout(now, action.data.awaited));
92
+ changes.push(...this.tryEagerTimeout(now, action.data.awaited));
87
93
  }
88
94
  result = this.taskSuspend(now, req);
89
95
  break;
90
96
  }
91
97
  case "task.halt": {
92
- changes.push(...this.tryAutoTimeout(now, req.data.id));
98
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
93
99
  result = this.taskHalt(now, req);
94
100
  break;
95
101
  }
96
102
  case "task.continue": {
97
- changes.push(...this.tryAutoTimeout(now, req.data.id));
103
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
98
104
  result = this.taskContinue(now, req);
99
105
  break;
100
106
  }
101
107
  case "task.fence": {
102
- changes.push(...this.tryAutoTimeout(now, req.data.id));
103
- changes.push(...this.tryAutoTimeout(now, req.data.action.data.id));
108
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
109
+ changes.push(...this.tryEagerTimeout(now, req.data.action.data.id));
104
110
  result = this.taskFence(now, req);
105
111
  break;
106
112
  }
@@ -192,13 +198,20 @@ export class Server {
192
198
  if (!promise) {
193
199
  return { response: this.response("promise.get", 404, "Promise not found"), changes: [] };
194
200
  }
195
- return { response: this.response("promise.get", 200, { promise: this.toPromiseRecord(promise) }), changes: [] };
201
+ return {
202
+ response: this.response("promise.get", 200, {
203
+ promise: this.toPromiseRecord(this.projectedPromise(now, promise)),
204
+ }),
205
+ changes: [],
206
+ };
196
207
  }
197
208
  promiseCreate(now, req) {
198
- const existing = this.promises.get(req.data.id);
199
- if (existing) {
209
+ const existingPromise = this.promises.get(req.data.id);
210
+ if (existingPromise) {
200
211
  return {
201
- response: this.response("promise.create", 200, { promise: this.toPromiseRecord(existing) }),
212
+ response: this.response("promise.create", 200, {
213
+ promise: this.toPromiseRecord(this.projectedPromise(now, existingPromise)),
214
+ }),
202
215
  changes: [],
203
216
  };
204
217
  }
@@ -214,13 +227,23 @@ export class Server {
214
227
  createdAt: req.data.timeoutAt,
215
228
  settledAt: req.data.timeoutAt,
216
229
  timeoutAt: req.data.timeoutAt,
217
- awaiters: new Set(),
218
- subscribers: new Set(),
230
+ callbacks: new Set(),
231
+ listeners: new Set(),
219
232
  };
220
233
  changes.push(this.setPromise(promise));
221
- changes.push(...this.enqueueSettle(req.data.id));
222
- changes.push(...this.resumeAwaiters(req.data.id, now));
223
- changes.push(...this.notifySubscribers(req.data.id));
234
+ // Create fulfilled task inline if promise has a target
235
+ const address = req.data.tags["resonate:target"];
236
+ if (address) {
237
+ changes.push(this.setTask({
238
+ id: req.data.id,
239
+ state: "fulfilled",
240
+ version: 0,
241
+ pid: undefined,
242
+ ttl: undefined,
243
+ resumes: new Set(),
244
+ }));
245
+ }
246
+ changes.push(...this.triggerSettlement(req.data.id, now));
224
247
  return {
225
248
  response: this.response("promise.create", 200, { promise: this.toPromiseRecord(promise) }),
226
249
  changes,
@@ -235,8 +258,8 @@ export class Server {
235
258
  createdAt: now,
236
259
  settledAt: null,
237
260
  timeoutAt: req.data.timeoutAt,
238
- awaiters: new Set(),
239
- subscribers: new Set(),
261
+ callbacks: new Set(),
262
+ listeners: new Set(),
240
263
  };
241
264
  changes.push(this.setPromise(promise));
242
265
  changes.push(this.setPTimeout({ id: req.data.id, timeout: req.data.timeoutAt }));
@@ -270,26 +293,28 @@ export class Server {
270
293
  if (!promise) {
271
294
  return { response: this.response("promise.settle", 404, "Promise not found"), changes: [] };
272
295
  }
273
- if (promise.state !== "pending") {
296
+ // Idempotent return: if the promise is already physically terminal OR
297
+ // pending past its deadline (logically settled), return the projected
298
+ // logical state without writing.
299
+ const projected = this.projectedPromise(now, promise);
300
+ if (projected.state !== "pending") {
274
301
  return {
275
- response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(promise) }),
302
+ response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(projected) }),
276
303
  changes: [],
277
304
  };
278
305
  }
279
306
  const changes = [];
280
- const settled = {
307
+ const settledPromise = {
281
308
  ...promise,
282
309
  state: req.data.state,
283
310
  value: req.data.value,
284
311
  settledAt: now,
285
312
  };
286
- changes.push(this.setPromise(settled));
313
+ changes.push(this.setPromise(settledPromise));
287
314
  changes.push(this.delPTimeout(req.data.id));
288
- changes.push(...this.enqueueSettle(req.data.id));
289
- changes.push(...this.resumeAwaiters(req.data.id, now));
290
- changes.push(...this.notifySubscribers(req.data.id));
315
+ changes.push(...this.triggerSettlement(req.data.id, now));
291
316
  return {
292
- response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(settled) }),
317
+ response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(settledPromise) }),
293
318
  changes,
294
319
  };
295
320
  }
@@ -307,13 +332,19 @@ export class Server {
307
332
  return { response: this.response("promise.register_callback", 422, "Awaiter has no address"), changes: [] };
308
333
  }
309
334
  const changes = [];
310
- // Add to awaiters if awaited is pending and awaiter is not settled
311
- if (awaitedPromise.state === "pending" && awaiterPromise.state === "pending") {
312
- awaitedPromise.awaiters.add(req.data.awaiter);
335
+ // Project for the logically-pending check and for the response the
336
+ // callback is written only when both sides are logically pending, and
337
+ // the response always reflects the projected awaited promise.
338
+ const awaitedProjected = this.projectedPromise(now, awaitedPromise);
339
+ const awaiterProjected = this.projectedPromise(now, awaiterPromise);
340
+ if (awaitedProjected.state === "pending" && awaiterProjected.state === "pending") {
341
+ awaitedPromise.callbacks.add(req.data.awaiter);
313
342
  changes.push(this.setPromise(awaitedPromise));
314
343
  }
315
344
  return {
316
- response: this.response("promise.register_callback", 200, { promise: this.toPromiseRecord(awaitedPromise) }),
345
+ response: this.response("promise.register_callback", 200, {
346
+ promise: this.toPromiseRecord(awaitedProjected),
347
+ }),
317
348
  changes,
318
349
  };
319
350
  }
@@ -323,13 +354,18 @@ export class Server {
323
354
  return { response: this.response("promise.register_listener", 404, "Promise not found"), changes: [] };
324
355
  }
325
356
  const changes = [];
326
- // Add subscriber if promise is pending
327
- if (promise.state === "pending") {
328
- promise.subscribers.add(req.data.address);
357
+ // Project for the logically-pending check and for the response — the
358
+ // listener is registered only when the promise is logically pending,
359
+ // and the response always reflects the projected awaited promise.
360
+ const projected = this.projectedPromise(now, promise);
361
+ if (projected.state === "pending") {
362
+ promise.listeners.add(req.data.address);
329
363
  changes.push(this.setPromise(promise));
330
364
  }
331
365
  return {
332
- response: this.response("promise.register_listener", 200, { promise: this.toPromiseRecord(promise) }),
366
+ response: this.response("promise.register_listener", 200, {
367
+ promise: this.toPromiseRecord(projected),
368
+ }),
333
369
  changes,
334
370
  };
335
371
  }
@@ -341,7 +377,12 @@ export class Server {
341
377
  if (!task) {
342
378
  return { response: this.response("task.get", 404, "Task not found"), changes: [] };
343
379
  }
344
- return { response: this.response("task.get", 200, { task: this.toTaskRecord(task) }), changes: [] };
380
+ return {
381
+ response: this.response("task.get", 200, {
382
+ task: this.toTaskRecord(this.projectedTask(now, task)),
383
+ }),
384
+ changes: [],
385
+ };
345
386
  }
346
387
  taskCreate(now, req) {
347
388
  const existingTask = this.tasks.get(req.data.action.data.id);
@@ -349,10 +390,12 @@ export class Server {
349
390
  const promise = this.getPromiseOrThrow(existingTask.id);
350
391
  // Pending → Acquired (acquire and return 200)
351
392
  if (existingTask.state === "pending") {
393
+ const newVersion = existingTask.version + 1;
352
394
  const changes = [];
353
395
  changes.push(this.setTask({
354
396
  ...existingTask,
355
397
  state: "acquired",
398
+ version: newVersion,
356
399
  pid: req.data.pid,
357
400
  ttl: req.data.ttl,
358
401
  resumes: new Set(),
@@ -363,6 +406,7 @@ export class Server {
363
406
  task: this.toTaskRecord({
364
407
  ...existingTask,
365
408
  state: "acquired",
409
+ version: newVersion,
366
410
  pid: req.data.pid,
367
411
  ttl: req.data.ttl,
368
412
  resumes: new Set(),
@@ -407,8 +451,8 @@ export class Server {
407
451
  createdAt: actionData.timeoutAt,
408
452
  settledAt: actionData.timeoutAt,
409
453
  timeoutAt: actionData.timeoutAt,
410
- awaiters: new Set(),
411
- subscribers: new Set(),
454
+ callbacks: new Set(),
455
+ listeners: new Set(),
412
456
  };
413
457
  changes.push(this.setPromise(promise));
414
458
  const task = {
@@ -439,8 +483,8 @@ export class Server {
439
483
  createdAt: now,
440
484
  settledAt: null,
441
485
  timeoutAt: actionData.timeoutAt,
442
- awaiters: new Set(),
443
- subscribers: new Set(),
486
+ callbacks: new Set(),
487
+ listeners: new Set(),
444
488
  };
445
489
  changes.push(this.setPromise(promise));
446
490
  changes.push(this.setPTimeout({ id: actionData.id, timeout: actionData.timeoutAt }));
@@ -448,7 +492,7 @@ export class Server {
448
492
  const task = {
449
493
  id: actionData.id,
450
494
  state: "acquired",
451
- version: 0,
495
+ version: 1,
452
496
  pid: req.data.pid,
453
497
  ttl: req.data.ttl,
454
498
  resumes: new Set(),
@@ -473,18 +517,34 @@ export class Server {
473
517
  if (task.state !== "pending") {
474
518
  return { response: this.response("task.acquire", 409, "Task not in pending state"), changes: [] };
475
519
  }
520
+ // Logical-state check: if the task's promise is logically settled,
521
+ // the task is logically fulfilled and cannot be acquired. This catches
522
+ // the projection-mode case where the physical task state has not yet
523
+ // converged.
524
+ const promise = this.getPromiseOrThrow(req.data.id);
525
+ if (this.projectedPromise(now, promise).state !== "pending") {
526
+ return { response: this.response("task.acquire", 409, "Task logically fulfilled"), changes: [] };
527
+ }
476
528
  if (task.version !== req.data.version) {
477
529
  return { response: this.response("task.acquire", 409, "Version mismatch"), changes: [] };
478
530
  }
531
+ const newVersion = task.version + 1;
479
532
  const changes = [];
480
- const promise = this.getPromiseOrThrow(req.data.id);
481
- changes.push(this.setTask({ ...task, state: "acquired", pid: req.data.pid, ttl: req.data.ttl, resumes: new Set() }));
533
+ changes.push(this.setTask({
534
+ ...task,
535
+ state: "acquired",
536
+ version: newVersion,
537
+ pid: req.data.pid,
538
+ ttl: req.data.ttl,
539
+ resumes: new Set(),
540
+ }));
482
541
  changes.push(this.setTTimeout({ id: req.data.id, type: 1, timeout: now + req.data.ttl }));
483
542
  return {
484
543
  response: this.response("task.acquire", 200, {
485
544
  task: this.toTaskRecord({
486
545
  ...task,
487
546
  state: "acquired",
547
+ version: newVersion,
488
548
  pid: req.data.pid,
489
549
  ttl: req.data.ttl,
490
550
  resumes: new Set(),
@@ -503,19 +563,24 @@ export class Server {
503
563
  if (task.state !== "acquired") {
504
564
  return { response: this.response("task.release", 409, "Task not acquired"), changes: [] };
505
565
  }
566
+ // Logical-state check: if the task's promise is logically settled,
567
+ // the task is logically fulfilled and cannot be released.
568
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
569
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
570
+ return { response: this.response("task.release", 409, "Task not acquired"), changes: [] };
571
+ }
506
572
  if (task.version !== req.data.version) {
507
573
  return { response: this.response("task.release", 409, "Version mismatch"), changes: [] };
508
574
  }
509
575
  const changes = [];
510
- const newVersion = task.version + 1;
511
- changes.push(this.setTask({ ...task, state: "pending", version: newVersion, pid: undefined, ttl: undefined }));
576
+ changes.push(this.setTask({ ...task, state: "pending", pid: undefined, ttl: undefined }));
512
577
  changes.push(this.setTTimeout({ id: req.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
513
578
  const promise = this.getPromiseOrThrow(req.data.id);
514
579
  const address = this.getAddressOrThrow(promise);
515
580
  changes.push(this.sendMessage(address, {
516
581
  kind: "execute",
517
582
  head: {},
518
- data: { task: { id: req.data.id, version: newVersion } },
583
+ data: { task: { id: req.data.id, version: task.version } },
519
584
  }));
520
585
  return { response: this.response("task.release", 200, {}), changes };
521
586
  }
@@ -527,31 +592,34 @@ export class Server {
527
592
  if (task.state !== "acquired") {
528
593
  return { response: this.response("task.fulfill", 409, "Task not acquired"), changes: [] };
529
594
  }
595
+ // Logical-state check: if the task's promise is logically settled, the
596
+ // task is logically fulfilled and cannot be fulfilled again.
597
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
598
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
599
+ return { response: this.response("task.fulfill", 409, "Task not acquired"), changes: [] };
600
+ }
530
601
  if (task.version !== req.data.version) {
531
602
  return { response: this.response("task.fulfill", 409, "Version mismatch"), changes: [] };
532
603
  }
533
- const settle = req.data.action.data;
534
- const promise = this.getPromiseOrThrow(settle.id);
604
+ const promise = this.getPromiseOrThrow(req.data.action.data.id);
535
605
  const changes = [];
536
606
  // Check if promise is already settled (possibly by auto-timeout above)
537
607
  if (promise.state !== "pending") {
538
608
  // Still fulfill the task but indicate the promise was already settled
539
- changes.push(...this.enqueueSettle(req.data.id));
609
+ changes.push(...this.triggerFulfilled(req.data.id));
540
610
  return { response: this.response("task.fulfill", 200, { promise: this.toPromiseRecord(promise) }), changes };
541
611
  }
542
612
  // Settle the promise
543
- const settled = {
613
+ const settledPromise = {
544
614
  ...promise,
545
- state: settle.state,
546
- value: settle.value ?? {},
615
+ state: req.data.action.data.state,
616
+ value: req.data.action.data.value ?? {},
547
617
  settledAt: now,
548
618
  };
549
- changes.push(this.setPromise(settled));
550
- changes.push(this.delPTimeout(settle.id));
551
- changes.push(...this.enqueueSettle(req.data.id));
552
- changes.push(...this.resumeAwaiters(settle.id, now));
553
- changes.push(...this.notifySubscribers(settle.id));
554
- return { response: this.response("task.fulfill", 200, { promise: this.toPromiseRecord(settled) }), changes };
619
+ changes.push(this.setPromise(settledPromise));
620
+ changes.push(this.delPTimeout(req.data.action.data.id));
621
+ changes.push(...this.triggerSettlement(req.data.id, now));
622
+ return { response: this.response("task.fulfill", 200, { promise: this.toPromiseRecord(settledPromise) }), changes };
555
623
  }
556
624
  taskSuspend(now, req) {
557
625
  const task = this.tasks.get(req.data.id);
@@ -561,36 +629,43 @@ export class Server {
561
629
  if (task.state !== "acquired") {
562
630
  return { response: this.response("task.suspend", 409, "Task not acquired"), changes: [] };
563
631
  }
632
+ // Logical-state check: if the task's promise is logically settled, the
633
+ // task is logically fulfilled and cannot be suspended.
634
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
635
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
636
+ return { response: this.response("task.suspend", 409, "Task not acquired"), changes: [] };
637
+ }
564
638
  if (task.version !== req.data.version) {
565
639
  return { response: this.response("task.suspend", 409, "Version mismatch"), changes: [] };
566
640
  }
567
641
  const changes = [];
568
- // Immediate resume stay acquired, clear all resumes
569
- if (task.resumes.size > 0) {
570
- changes.push(this.setTask({ ...task, resumes: new Set() }));
571
- return { response: this.response("task.suspend", 300, { preload: this.preload(req.data.id) }), changes };
572
- }
573
- // Register this task as an awaiter on each awaited promise.
574
- // The awaiter is always req.data.id (validated above).
575
- const triggers = [];
642
+ // First pass: validate all promises exist and classify as pending or settled.
643
+ // Callbacks are only registered when ALL promises are pending (spec transition #40).
644
+ // If any promise is already settled, return 300 immediately with no callbacks (spec transition #39).
645
+ const pendingPromises = [];
646
+ let hasSettled = false;
576
647
  for (const action of req.data.actions) {
577
648
  const awaitedPromise = this.promises.get(action.data.awaited);
578
649
  if (!awaitedPromise) {
579
650
  return { response: this.response("task.suspend", 422, {}), changes: [] };
580
651
  }
581
652
  if (awaitedPromise.state === "pending") {
582
- awaitedPromise.awaiters.add(req.data.id);
583
- changes.push(this.setPromise(awaitedPromise));
653
+ pendingPromises.push(awaitedPromise);
584
654
  }
585
655
  else {
586
- // Already settled → immediate trigger
587
- triggers.push(action.data.awaited);
656
+ hasSettled = true;
588
657
  }
589
658
  }
590
- if (triggers.length > 0) {
659
+ // Immediate resume stay acquired, clear all resumes, no callbacks registered
660
+ if (hasSettled) {
661
+ changes.push(this.setTask({ ...task, resumes: new Set() }));
591
662
  return { response: this.response("task.suspend", 300, { preload: this.preload(req.data.id) }), changes };
592
663
  }
593
- // Actually suspend
664
+ // All pending: register callbacks then suspend
665
+ for (const awaitedPromise of pendingPromises) {
666
+ awaitedPromise.callbacks.add(req.data.id);
667
+ changes.push(this.setPromise(awaitedPromise));
668
+ }
594
669
  changes.push(this.setTask({ ...task, state: "suspended", pid: undefined, ttl: undefined, resumes: new Set() }));
595
670
  changes.push(this.delTTimeout(req.data.id));
596
671
  return { response: this.response("task.suspend", 200, {}), changes };
@@ -619,15 +694,14 @@ export class Server {
619
694
  return { response: this.response("task.continue", 409, "Task is not halted"), changes: [] };
620
695
  }
621
696
  const changes = [];
622
- const newVersion = task.version + 1;
623
- changes.push(this.setTask({ ...task, state: "pending", version: newVersion }));
697
+ changes.push(this.setTask({ ...task, state: "pending" }));
624
698
  changes.push(this.setTTimeout({ id: req.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
625
699
  const promise = this.getPromiseOrThrow(req.data.id);
626
700
  const address = this.getAddressOrThrow(promise);
627
701
  changes.push(this.sendMessage(address, {
628
702
  kind: "execute",
629
703
  head: {},
630
- data: { task: { id: req.data.id, version: newVersion } },
704
+ data: { task: { id: req.data.id, version: task.version } },
631
705
  }));
632
706
  return { response: this.response("task.continue", 200, {}), changes };
633
707
  }
@@ -639,6 +713,12 @@ export class Server {
639
713
  if (task.state !== "acquired") {
640
714
  return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
641
715
  }
716
+ // Logical-state check: if the task's promise is logically settled,
717
+ // the task is logically fulfilled and the fence fails.
718
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
719
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
720
+ return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
721
+ }
642
722
  if (task.version !== req.data.version) {
643
723
  return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
644
724
  }
@@ -650,8 +730,12 @@ export class Server {
650
730
  else {
651
731
  inner = this.promiseSettle(now, action);
652
732
  }
733
+ const actionResponse = {
734
+ ...inner.response,
735
+ head: { corrId: action.head.corrId, status: inner.response.head.status, version: action.head.version },
736
+ };
653
737
  return {
654
- response: this.response("task.fence", 200, { action: inner.response, preload: [] }),
738
+ response: this.response("task.fence", 200, { action: actionResponse, preload: [] }),
655
739
  changes: inner.changes,
656
740
  };
657
741
  }
@@ -662,6 +746,12 @@ export class Server {
662
746
  if (!task || task.state !== "acquired" || task.version !== ref.version || task.pid !== req.data.pid) {
663
747
  continue;
664
748
  }
749
+ // Logical-state check: if the task's promise is logically settled,
750
+ // the task is logically fulfilled and its lease cannot be refreshed.
751
+ const promise = this.getPromiseOrThrow(ref.id);
752
+ if (this.projectedPromise(now, promise).state !== "pending") {
753
+ continue;
754
+ }
665
755
  const ttl = task.ttl ?? 30000;
666
756
  changes.push(this.setTTimeout({ id: ref.id, type: 1, timeout: now + ttl }));
667
757
  }
@@ -678,10 +768,10 @@ export class Server {
678
768
  return { response: this.response("schedule.get", 200, { schedule: this.toScheduleRecord(schedule) }), changes: [] };
679
769
  }
680
770
  scheduleCreate(now, req) {
681
- const existing = this.schedules.get(req.data.id);
682
- if (existing) {
771
+ const existingSchedule = this.schedules.get(req.data.id);
772
+ if (existingSchedule) {
683
773
  return {
684
- response: this.response("schedule.create", 200, { schedule: this.toScheduleRecord(existing) }),
774
+ response: this.response("schedule.create", 200, { schedule: this.toScheduleRecord(existingSchedule) }),
685
775
  changes: [],
686
776
  };
687
777
  }
@@ -738,8 +828,8 @@ export class Server {
738
828
  response: this.response("debug.snap", 200, {
739
829
  promises: Array.from(this.promises.values()).map((p) => this.toPromiseRecord(p)),
740
830
  promiseTimeouts: this.pTimeouts,
741
- callbacks: Array.from(this.promises.values()).flatMap((p) => [...p.awaiters].map((awaiter) => ({ awaiter, awaited: p.id }))),
742
- listeners: Array.from(this.promises.values()).flatMap((p) => [...p.subscribers].map((address) => ({ id: p.id, address }))),
831
+ callbacks: Array.from(this.promises.values()).flatMap((p) => [...p.callbacks].map((awaiter) => ({ awaiter, awaited: p.id }))),
832
+ listeners: Array.from(this.promises.values()).flatMap((p) => [...p.listeners].map((address) => ({ id: p.id, address }))),
743
833
  tasks: Array.from(this.tasks.values()).map((t) => this.toTaskRecord(t)),
744
834
  taskTimeouts: this.tTimeouts,
745
835
  messages: this.outgoing,
@@ -792,11 +882,11 @@ export class Server {
792
882
  // order promises appear in pTimeouts.
793
883
  //
794
884
  // Phase 1: Settle all expired promises (state change only).
795
- // Phase 2: Fulfill tasks whose own promise settled (enqueueSettle).
796
- // Phase 3: Resume suspended awaiters of settled promises (resumeAwaiters).
885
+ // Phase 2: Fulfill tasks whose own promise settled (triggerFulfilled).
886
+ // Phase 3: Resume suspended callbacks of settled promises (triggerCallbacks).
797
887
  //
798
888
  // Phase 2 before phase 3 ensures that a task whose own promise settled
799
- // is fulfilled before resumeAwaiters runs. This prevents a spurious
889
+ // is fulfilled before triggerCallbacks runs. This prevents a spurious
800
890
  // suspended → pending (version++) → fulfilled path for tasks that
801
891
  // should go directly suspended → fulfilled.
802
892
  const settledIds = [];
@@ -818,26 +908,25 @@ export class Server {
818
908
  }
819
909
  // Phase 2: Fulfill tasks whose own promise settled
820
910
  for (const id of settledIds) {
821
- changes.push(...this.enqueueSettle(id));
911
+ changes.push(...this.triggerFulfilled(id));
822
912
  }
823
- // Phase 3: Resume suspended awaiters and notify subscribers
913
+ // Phase 3: Resume suspended callbacks and notify listeners
824
914
  for (const id of settledIds) {
825
- changes.push(...this.resumeAwaiters(id, now));
826
- changes.push(...this.notifySubscribers(id));
915
+ changes.push(...this.triggerCallbacks(id, now));
916
+ changes.push(...this.triggerListeners(id));
827
917
  }
828
918
  for (const action of actions) {
829
919
  if (action.kind === "task.release") {
830
920
  const task = this.getTaskOrThrow(action.data.id);
831
921
  if (task.state === "acquired" && task.version === action.data.version) {
832
- const newVersion = task.version + 1;
833
- changes.push(this.setTask({ ...task, state: "pending", version: newVersion, pid: undefined, ttl: undefined }));
922
+ changes.push(this.setTask({ ...task, state: "pending", pid: undefined, ttl: undefined }));
834
923
  changes.push(this.setTTimeout({ id: action.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
835
924
  const promise = this.getPromiseOrThrow(action.data.id);
836
925
  const address = this.getAddressOrThrow(promise);
837
926
  changes.push(this.sendMessage(address, {
838
927
  kind: "execute",
839
928
  head: {},
840
- data: { task: { id: action.data.id, version: newVersion } },
929
+ data: { task: { id: action.data.id, version: task.version } },
841
930
  }));
842
931
  }
843
932
  }
@@ -865,12 +954,12 @@ export class Server {
865
954
  .replaceAll("{{.timestamp}}", String(st.timeout));
866
955
  const { changes: createChanges } = this.promiseCreate(now, {
867
956
  kind: "promise.create",
868
- head: { corrId: randomUUID(), version: VERSION },
957
+ head: { corrId: "", version: "" },
869
958
  data: {
870
959
  id: promiseId,
871
960
  timeoutAt: st.timeout + schedule.promiseTimeout,
872
961
  param: schedule.promiseParam,
873
- tags: schedule.promiseTags,
962
+ tags: { ...schedule.promiseTags, "resonate:schedule": schedule.id },
874
963
  },
875
964
  });
876
965
  changes.push(...createChanges);
@@ -892,7 +981,7 @@ export class Server {
892
981
  // CONVERTERS
893
982
  // ===========================================================================
894
983
  toPromiseRecord(p) {
895
- const { awaiters, subscribers, settledAt, ...rest } = p;
984
+ const { callbacks, listeners, settledAt, ...rest } = p;
896
985
  return settledAt != null ? { ...rest, settledAt } : rest;
897
986
  }
898
987
  toTaskRecord(t) {
@@ -923,7 +1012,9 @@ export class Server {
923
1012
  // ===========================================================================
924
1013
  // HELPERS
925
1014
  // ===========================================================================
926
- tryAutoTimeout(now, id) {
1015
+ tryEagerTimeout(now, id) {
1016
+ if (this.timeoutMode !== "eager")
1017
+ return [];
927
1018
  const promise = this.promises.get(id);
928
1019
  if (!promise || promise.state !== "pending" || now < promise.timeoutAt) {
929
1020
  return [];
@@ -932,50 +1023,37 @@ export class Server {
932
1023
  const state = this.timeoutState(promise.tags);
933
1024
  changes.push(this.setPromise({ ...promise, state, settledAt: promise.timeoutAt }));
934
1025
  changes.push(this.delPTimeout(id));
935
- changes.push(...this.enqueueSettle(id));
936
- changes.push(...this.resumeAwaiters(id, now));
937
- changes.push(...this.notifySubscribers(id));
1026
+ changes.push(...this.triggerSettlement(id, now));
938
1027
  return changes;
939
1028
  }
940
- enqueueSettle(promiseId) {
1029
+ triggerSettlement(id, now) {
1030
+ return [...this.triggerFulfilled(id), ...this.triggerCallbacks(id, now), ...this.triggerListeners(id)];
1031
+ }
1032
+ triggerFulfilled(promiseId) {
941
1033
  const task = this.tasks.get(promiseId);
942
- if (!task) {
943
- const promise = this.promises.get(promiseId);
944
- if (!promise?.tags["resonate:target"])
945
- return [];
946
- return [
947
- this.setTask({
948
- id: promiseId,
949
- state: "fulfilled",
950
- version: 0,
951
- pid: undefined,
952
- ttl: undefined,
953
- resumes: new Set(),
954
- }),
955
- ];
956
- }
1034
+ if (!task)
1035
+ return [];
957
1036
  if (task.state === "fulfilled")
958
1037
  return [];
959
1038
  const changes = [];
960
1039
  changes.push(this.setTask({ ...task, state: "fulfilled", pid: undefined, ttl: undefined, resumes: new Set() }));
961
1040
  changes.push(this.delTTimeout(promiseId));
962
- // Remove this task from all promise awaiters (delete callbacks where awaiter_id = task_id)
1041
+ // Remove this task from all promise callbacks (delete callbacks where awaiter_id = task_id)
963
1042
  for (const [, promise] of this.promises) {
964
- if (promise.awaiters.delete(promiseId)) {
1043
+ if (promise.callbacks.delete(promiseId)) {
965
1044
  changes.push(this.setPromise(promise));
966
1045
  }
967
1046
  }
968
1047
  return changes;
969
1048
  }
970
- resumeAwaiters(promiseId, now) {
1049
+ triggerCallbacks(promiseId, now) {
971
1050
  const settledPromise = this.getPromiseOrThrow(promiseId);
972
1051
  const changes = [];
973
1052
  // Resume or buffer for all tasks that were awaiting this promise
974
- for (const awaiterId of settledPromise.awaiters) {
1053
+ for (const awaiterId of settledPromise.callbacks) {
975
1054
  const task = this.getTaskOrThrow(awaiterId);
976
1055
  if (task.state === "suspended") {
977
- const newVersion = task.version + 1;
978
- changes.push(this.setTask({ ...task, state: "pending", version: newVersion, resumes: new Set([promiseId]) }));
1056
+ changes.push(this.setTask({ ...task, state: "pending", resumes: new Set([promiseId]) }));
979
1057
  // Add task timeout entry back (type=0 for pending retry)
980
1058
  changes.push(this.setTTimeout({
981
1059
  id: awaiterId,
@@ -987,7 +1065,7 @@ export class Server {
987
1065
  changes.push(this.sendMessage(address, {
988
1066
  kind: "execute",
989
1067
  head: {},
990
- data: { task: { id: awaiterId, version: newVersion } },
1068
+ data: { task: { id: awaiterId, version: task.version } },
991
1069
  }));
992
1070
  }
993
1071
  else if (task.state === "pending" || task.state === "acquired" || task.state === "halted") {
@@ -996,24 +1074,24 @@ export class Server {
996
1074
  changes.push(this.setTask(task));
997
1075
  }
998
1076
  }
999
- // Clear awaiters after processing
1000
- settledPromise.awaiters.clear();
1077
+ // Clear callbacks after processing
1078
+ settledPromise.callbacks.clear();
1001
1079
  changes.push(this.setPromise(settledPromise));
1002
1080
  return changes;
1003
1081
  }
1004
- notifySubscribers(promiseId) {
1082
+ triggerListeners(promiseId) {
1005
1083
  const promise = this.getPromiseOrThrow(promiseId);
1006
- if (promise.subscribers.size === 0)
1084
+ if (promise.listeners.size === 0)
1007
1085
  return [];
1008
1086
  const changes = [];
1009
- for (const address of promise.subscribers) {
1087
+ for (const address of promise.listeners) {
1010
1088
  changes.push(this.sendMessage(address, {
1011
1089
  kind: "unblock",
1012
1090
  head: {},
1013
1091
  data: { promise: this.toPromiseRecord(promise) },
1014
1092
  }));
1015
1093
  }
1016
- promise.subscribers.clear();
1094
+ promise.listeners.clear();
1017
1095
  changes.push(this.setPromise(promise));
1018
1096
  return changes;
1019
1097
  }
@@ -1035,6 +1113,42 @@ export class Server {
1035
1113
  timeoutState(tags) {
1036
1114
  return tags["resonate:timer"] === "true" ? "resolved" : "rejected_timedout";
1037
1115
  }
1116
+ // Projection: return the LOGICAL view of a promise without mutating
1117
+ // storage. When physical state is `pending` and `now >= timeoutAt`, the
1118
+ // logical state is the terminal state implied by the timer tag. Used at
1119
+ // response-construction sites in read paths so callers always observe
1120
+ // logical state regardless of whether convergence has happened yet.
1121
+ //
1122
+ // Unguarded by timeoutMode: when timeoutMode === "eager", the physical
1123
+ // state already matches the logical state by the time this runs (because
1124
+ // tryEagerTimeout fired at the start of the action), so the projection
1125
+ // condition is never met and this is a no-op.
1126
+ projectedPromise(now, promise) {
1127
+ if (promise.state !== "pending" || now < promise.timeoutAt) {
1128
+ return promise;
1129
+ }
1130
+ return {
1131
+ ...promise,
1132
+ state: this.timeoutState(promise.tags),
1133
+ settledAt: promise.timeoutAt,
1134
+ };
1135
+ }
1136
+ // Projection: return the LOGICAL view of a task without mutating storage.
1137
+ // A task's logical state is bound to its promise's logical state — when
1138
+ // the promise is logically settled, the task is logically fulfilled.
1139
+ //
1140
+ // Unguarded for the same reason as projectedPromise.
1141
+ projectedTask(now, task) {
1142
+ const promise = this.promises.get(task.id);
1143
+ if (!promise)
1144
+ return task;
1145
+ const projected = this.projectedPromise(now, promise);
1146
+ if (projected.state === "pending")
1147
+ return task;
1148
+ if (task.state === "fulfilled")
1149
+ return task;
1150
+ return { ...task, state: "fulfilled", pid: undefined, ttl: undefined };
1151
+ }
1038
1152
  getPromiseOrThrow(id) {
1039
1153
  const promise = this.promises.get(id);
1040
1154
  if (!promise) {
@@ -1180,6 +1294,9 @@ export class LocalNetwork {
1180
1294
  this.anycast = `local://any@${group}/${pid}`;
1181
1295
  }
1182
1296
  // -- Network ---------------------------------------------------------------
1297
+ match(target) {
1298
+ return `local://any@${target}`;
1299
+ }
1183
1300
  async init() {
1184
1301
  if (this.started)
1185
1302
  return;