@cosmonapse/sdk 0.1.3 → 0.1.4

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/dist/index.js CHANGED
@@ -55,7 +55,13 @@ var SignalType = {
55
55
  IMPRINT: "IMPRINT",
56
56
  IMPRINTED: "IMPRINTED",
57
57
  // Discovery [C]
58
- DISCOVER: "DISCOVER"
58
+ DISCOVER: "DISCOVER",
59
+ // Workflow control [C] - cooperative cancellation of a whole trace.
60
+ // STOP is broadcast on the trace; every Dendrite filters by trace_id,
61
+ // cancels its in-flight work + engram I/O, optionally rolls back Engram
62
+ // writes via the saga journal, then acks with STOPPED.
63
+ STOP: "STOP",
64
+ STOPPED: "STOPPED"
59
65
  };
60
66
  var AXON_TYPES = /* @__PURE__ */ new Set([
61
67
  SignalType.AGENT_OUTPUT,
@@ -92,7 +98,11 @@ var SYNAPSE_TYPES = /* @__PURE__ */ new Set([
92
98
  SignalType.RECALL,
93
99
  SignalType.RECALLED,
94
100
  SignalType.IMPRINT,
95
- SignalType.IMPRINTED
101
+ SignalType.IMPRINTED,
102
+ // Workflow control - STOP is orchestrator-gated (see Dendrite role gate);
103
+ // STOPPED is the per-Dendrite ack.
104
+ SignalType.STOP,
105
+ SignalType.STOPPED
96
106
  ]);
97
107
  function normalizeDirected(d) {
98
108
  if (d === null || d === void 0) return null;
@@ -554,6 +564,237 @@ function imprintedSignal(args) {
554
564
  meta: args.meta ?? {}
555
565
  });
556
566
  }
567
+ function stopSignal(args) {
568
+ const payload = { rollback: Boolean(args.rollback) };
569
+ if (args.reason !== void 0) payload["reason"] = args.reason;
570
+ return createSignal({
571
+ type: SignalType.STOP,
572
+ trace_id: args.traceId,
573
+ parent_id: args.parentId ?? null,
574
+ directed: args.directed ?? null,
575
+ payload,
576
+ meta: args.meta ?? {}
577
+ });
578
+ }
579
+ function stoppedSignal(args) {
580
+ const payload = {
581
+ rolled_back: Boolean(args.rolledBack),
582
+ cancelled: args.cancelled ?? 0,
583
+ compensated: args.compensated ?? 0
584
+ };
585
+ if (args.node !== void 0) payload["node"] = args.node;
586
+ return createSignal({
587
+ type: SignalType.STOPPED,
588
+ trace_id: args.traceId,
589
+ parent_id: args.parentId ?? null,
590
+ directed: args.directed ?? null,
591
+ payload,
592
+ meta: args.meta ?? {}
593
+ });
594
+ }
595
+
596
+ // src/pathway.ts
597
+ var TERMINAL_TYPES = /* @__PURE__ */ new Set([
598
+ SignalType.FINAL,
599
+ SignalType.ERROR
600
+ ]);
601
+ var WAIT_TYPES = /* @__PURE__ */ new Set([
602
+ SignalType.AGENT_OUTPUT,
603
+ SignalType.CLARIFICATION,
604
+ SignalType.PERMISSION,
605
+ SignalType.ERROR,
606
+ SignalType.FINAL
607
+ ]);
608
+ var SCOPE_TERMINAL_TYPES = /* @__PURE__ */ new Set([
609
+ SignalType.FINAL,
610
+ SignalType.ERROR,
611
+ SignalType.CLARIFICATION,
612
+ SignalType.PERMISSION
613
+ ]);
614
+ var PATHWAY_TYPES = new Set(
615
+ Object.values(SignalType).filter(
616
+ (t) => t !== SignalType.TASK && t !== SignalType.REGISTER && t !== SignalType.DEREGISTER && t !== SignalType.HEARTBEAT && t !== SignalType.DISCOVER
617
+ )
618
+ );
619
+ var PathwayClosedError = class extends Error {
620
+ constructor(message) {
621
+ super(message);
622
+ this.name = "PathwayClosedError";
623
+ }
624
+ };
625
+ var Pathway = class {
626
+ traceId;
627
+ parentId;
628
+ role;
629
+ scope;
630
+ scopeFilter;
631
+ onCloseHook;
632
+ handlers = /* @__PURE__ */ new Map();
633
+ waiters = [];
634
+ buffered = [];
635
+ closed_ = false;
636
+ // Async iteration: a pull queue of pending `next()` resolvers and a push
637
+ // queue of undelivered values. `null` is the close sentinel.
638
+ iterPush = [];
639
+ iterPull = [];
640
+ constructor(opts) {
641
+ const scope = opts.scope ?? "all";
642
+ if (scope !== "all" && scope !== "terminal") {
643
+ throw new Error(`scope must be 'all' or 'terminal', got '${scope}'`);
644
+ }
645
+ this.traceId = opts.traceId;
646
+ this.parentId = opts.parentId ?? null;
647
+ this.role = opts.role ?? "originator";
648
+ this.scope = scope;
649
+ this.scopeFilter = scope === "terminal" ? SCOPE_TERMINAL_TYPES : null;
650
+ this.onCloseHook = opts.onClose;
651
+ }
652
+ get closed() {
653
+ return this.closed_;
654
+ }
655
+ // -- consumer shape #1: wait ---------------------------------------
656
+ /** Resolve on the next AGENT_OUTPUT, CLARIFICATION, PERMISSION, ERROR or
657
+ * FINAL. Rejects with PathwayClosedError if the Pathway closes first, and
658
+ * with a TimeoutError-named Error if `timeoutMs` elapses. */
659
+ async wait(timeoutMs) {
660
+ return this.waitForTypes(WAIT_TYPES, timeoutMs);
661
+ }
662
+ /** Resolve on the next Signal of the given type. */
663
+ async waitFor(type, timeoutMs) {
664
+ return this.waitForTypes(/* @__PURE__ */ new Set([type]), timeoutMs);
665
+ }
666
+ async waitForTypes(types, timeoutMs) {
667
+ for (let i = 0; i < this.buffered.length; i++) {
668
+ const sig = this.buffered[i];
669
+ if (types.has(sig.type)) {
670
+ this.buffered.splice(i, 1);
671
+ return sig;
672
+ }
673
+ }
674
+ if (this.closed_) {
675
+ throw new PathwayClosedError(`Pathway for trace '${this.traceId}' is closed`);
676
+ }
677
+ return new Promise((resolve, reject) => {
678
+ const waiter = { types, resolve, reject, settled: false };
679
+ let timer = null;
680
+ const settle = (fn) => (a) => {
681
+ if (waiter.settled) return;
682
+ waiter.settled = true;
683
+ if (timer !== null) clearTimeout(timer);
684
+ this.waiters = this.waiters.filter((w) => w !== waiter);
685
+ fn(a);
686
+ };
687
+ waiter.resolve = settle(resolve);
688
+ waiter.reject = settle(reject);
689
+ if (timeoutMs !== void 0) {
690
+ timer = setTimeout(() => {
691
+ const err = new Error(
692
+ `Pathway.wait timed out after ${timeoutMs}ms on trace '${this.traceId}'`
693
+ );
694
+ err.name = "TimeoutError";
695
+ waiter.reject(err);
696
+ }, timeoutMs);
697
+ }
698
+ this.waiters.push(waiter);
699
+ });
700
+ }
701
+ // -- consumer shape #2: callbacks ----------------------------------
702
+ /** Register a callback fired for each Signal of the given type. */
703
+ on(type, fn) {
704
+ const list = this.handlers.get(type) ?? [];
705
+ list.push(fn);
706
+ this.handlers.set(type, list);
707
+ return fn;
708
+ }
709
+ // -- consumer shape #3: async iteration ----------------------------
710
+ [Symbol.asyncIterator]() {
711
+ return {
712
+ next: () => {
713
+ if (this.iterPush.length > 0) {
714
+ const v = this.iterPush.shift();
715
+ return Promise.resolve(
716
+ v === null ? { value: void 0, done: true } : { value: v, done: false }
717
+ );
718
+ }
719
+ if (this.closed_) {
720
+ return Promise.resolve({ value: void 0, done: true });
721
+ }
722
+ return new Promise((resolve) => this.iterPull.push(resolve));
723
+ }
724
+ };
725
+ }
726
+ iterEmit(v) {
727
+ const pull = this.iterPull.shift();
728
+ if (pull) {
729
+ pull(v === null ? { value: void 0, done: true } : { value: v, done: false });
730
+ } else {
731
+ this.iterPush.push(v);
732
+ }
733
+ }
734
+ // -- lifecycle ------------------------------------------------------
735
+ /** Close the Pathway. Idempotent. Pending waits reject with
736
+ * PathwayClosedError; iteration completes; the onClose hook fires once. */
737
+ async close() {
738
+ if (this.closed_) return;
739
+ this.closed_ = true;
740
+ for (const w of [...this.waiters]) {
741
+ w.reject(
742
+ new PathwayClosedError(
743
+ `Pathway for trace '${this.traceId}' closed before a matching Signal arrived`
744
+ )
745
+ );
746
+ }
747
+ this.waiters = [];
748
+ this.iterEmit(null);
749
+ if (this.onCloseHook) {
750
+ try {
751
+ await this.onCloseHook(this);
752
+ } catch {
753
+ }
754
+ }
755
+ }
756
+ /** `await using pathway = ...` support. */
757
+ async [Symbol.asyncDispose]() {
758
+ await this.close();
759
+ }
760
+ // -- internal: signal delivery (called by the owning Dendrite) ------
761
+ /** @internal */
762
+ async _deliver(signal) {
763
+ if (this.closed_) return;
764
+ if (this.scopeFilter !== null && !this.scopeFilter.has(signal.type)) {
765
+ await this.fireHandlers(signal);
766
+ if (TERMINAL_TYPES.has(signal.type)) await this.close();
767
+ return;
768
+ }
769
+ let consumed = false;
770
+ for (const w of [...this.waiters]) {
771
+ if (w.types.has(signal.type)) {
772
+ w.resolve(signal);
773
+ consumed = true;
774
+ }
775
+ }
776
+ if (!consumed) this.buffered.push(signal);
777
+ await this.fireHandlers(signal);
778
+ this.iterEmit(signal);
779
+ if (TERMINAL_TYPES.has(signal.type)) await this.close();
780
+ }
781
+ async fireHandlers(signal) {
782
+ for (const h of this.handlers.get(signal.type) ?? []) {
783
+ try {
784
+ await h(signal);
785
+ } catch {
786
+ }
787
+ }
788
+ }
789
+ };
790
+
791
+ // src/retry.ts
792
+ function defaultRetryOn(outcome) {
793
+ if (outcome instanceof PathwayClosedError) return true;
794
+ if (outcome instanceof Error) return outcome.name === "TimeoutError";
795
+ const sig = outcome;
796
+ return sig.type === SignalType.ERROR && Boolean(sig.payload?.["recoverable"]);
797
+ }
557
798
 
558
799
  // src/synapse.ts
559
800
  var MemorySubscription = class {
@@ -1911,6 +2152,49 @@ var EngramOverloaded = class extends EngramError {
1911
2152
  };
1912
2153
  var Engram = class {
1913
2154
  version = null;
2155
+ // ----------------------------------------------------------------------
2156
+ // Saga / compensating-log rollback
2157
+ // ----------------------------------------------------------------------
2158
+ // A backend opts in by calling `sagaRecord` from inside `imprint` with the
2159
+ // inverse op needed to undo the write it is about to apply. `compensate`
2160
+ // then replays those inverses in reverse (LIFO) through the public
2161
+ // `imprint` path with no traceId/imprintId (so they neither re-journal nor
2162
+ // consume idempotency keys). Every inverse is itself a valid
2163
+ // add/upsert/delete, so this is fully backend-agnostic.
2164
+ sagaJournal = /* @__PURE__ */ new Map();
2165
+ sagaRecord(traceId, op, entry, mergeKey) {
2166
+ if (!traceId) return;
2167
+ let j = this.sagaJournal.get(traceId);
2168
+ if (!j) {
2169
+ j = [];
2170
+ this.sagaJournal.set(traceId, j);
2171
+ }
2172
+ j.push({ op, entry, mergeKey });
2173
+ }
2174
+ /** Reverse every journaled write for `traceId` (LIFO) and discard the
2175
+ * journal. Returns the number of inverse ops applied. Best-effort. Only
2176
+ * Engram state is reversed - external side effects are out of scope. */
2177
+ async compensate(traceId) {
2178
+ const inverses = this.sagaJournal.get(traceId);
2179
+ if (!inverses) return 0;
2180
+ this.sagaJournal.delete(traceId);
2181
+ let applied = 0;
2182
+ for (let i = inverses.length - 1; i >= 0; i--) {
2183
+ const inv = inverses[i];
2184
+ try {
2185
+ const opts = inv.mergeKey !== void 0 ? { mergeKey: inv.mergeKey } : {};
2186
+ await this.imprint(inv.op, inv.entry, opts);
2187
+ applied++;
2188
+ } catch {
2189
+ }
2190
+ }
2191
+ return applied;
2192
+ }
2193
+ /** Discard the trace's saga journal without reversing anything. Called at
2194
+ * the workflow commit point (FINAL/ERROR on the trace). */
2195
+ async commit(traceId) {
2196
+ this.sagaJournal.delete(traceId);
2197
+ }
1914
2198
  /** Return false if this Engram cannot satisfy the query. Default: serve all. */
1915
2199
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1916
2200
  async canServe(_query) {
@@ -2009,6 +2293,7 @@ var InMemoryEngram = class extends Engram {
2009
2293
  async imprint(op, entry, opts = {}) {
2010
2294
  const t0 = Date.now();
2011
2295
  const mergeKey = opts.mergeKey ?? null;
2296
+ const traceId = opts.traceId;
2012
2297
  const tookMs = () => Date.now() - t0;
2013
2298
  if (opts.imprintId !== void 0) {
2014
2299
  const seen = this.imprintSeen.get(opts.imprintId);
@@ -2031,6 +2316,7 @@ var InMemoryEngram = class extends Engram {
2031
2316
  this.store(ent);
2032
2317
  resultingId = ent.id;
2033
2318
  version = ent.version;
2319
+ this.sagaRecord(traceId, "delete", { id: ent.id });
2034
2320
  } else if (op === "append") {
2035
2321
  let ent = this.makeEntry(entry, mergeKey);
2036
2322
  while (this.entries.has(ent.id)) {
@@ -2039,11 +2325,18 @@ var InMemoryEngram = class extends Engram {
2039
2325
  this.store(ent);
2040
2326
  resultingId = ent.id;
2041
2327
  version = ent.version;
2328
+ this.sagaRecord(traceId, "delete", { id: ent.id });
2042
2329
  } else if (op === "upsert") {
2043
2330
  const existingIds = this.byMergeKey.get(mergeKey ?? "") ?? [];
2044
2331
  const targetId = existingIds[existingIds.length - 1];
2045
2332
  const old = targetId !== void 0 ? this.entries.get(targetId) : void 0;
2046
2333
  if (old !== void 0) {
2334
+ this.sagaRecord(
2335
+ traceId,
2336
+ "upsert",
2337
+ { id: old.id, content: structuredClone(old.content), tags: [...old.tags], meta: structuredClone(old.extra) },
2338
+ old.mergeKey ?? void 0
2339
+ );
2047
2340
  const next = this.makeEntry({ ...entry, id: old.id }, mergeKey);
2048
2341
  next.createdAt = old.createdAt;
2049
2342
  next.version = old.version + 1;
@@ -2055,6 +2348,7 @@ var InMemoryEngram = class extends Engram {
2055
2348
  this.store(ent);
2056
2349
  resultingId = ent.id;
2057
2350
  version = ent.version;
2351
+ this.sagaRecord(traceId, "delete", { id: ent.id });
2058
2352
  }
2059
2353
  } else if (op === "merge") {
2060
2354
  const existingIds = this.byMergeKey.get(mergeKey ?? "") ?? [];
@@ -2063,6 +2357,12 @@ var InMemoryEngram = class extends Engram {
2063
2357
  if (old === void 0) {
2064
2358
  return receipt(this.engramId, op, { error: `no entry for merge_key='${mergeKey}'`, tookMs: tookMs() });
2065
2359
  }
2360
+ this.sagaRecord(
2361
+ traceId,
2362
+ "upsert",
2363
+ { id: old.id, content: structuredClone(old.content), tags: [...old.tags], meta: structuredClone(old.extra) },
2364
+ old.mergeKey ?? void 0
2365
+ );
2066
2366
  const now = (/* @__PURE__ */ new Date()).toISOString();
2067
2367
  const next = {
2068
2368
  id: old.id,
@@ -2089,6 +2389,13 @@ var InMemoryEngram = class extends Engram {
2089
2389
  if (targetId === null || !this.entries.has(targetId)) {
2090
2390
  return receipt(this.engramId, op, { tookMs: tookMs() });
2091
2391
  }
2392
+ const old = this.entries.get(targetId);
2393
+ this.sagaRecord(
2394
+ traceId,
2395
+ "add",
2396
+ { id: old.id, content: structuredClone(old.content), tags: [...old.tags], meta: structuredClone(old.extra) },
2397
+ old.mergeKey ?? void 0
2398
+ );
2092
2399
  this.evict(targetId);
2093
2400
  resultingId = targetId;
2094
2401
  version = null;
@@ -2995,201 +3302,6 @@ function parseMcpIntents(raw) {
2995
3302
  return raw;
2996
3303
  }
2997
3304
 
2998
- // src/pathway.ts
2999
- var TERMINAL_TYPES = /* @__PURE__ */ new Set([
3000
- SignalType.FINAL,
3001
- SignalType.ERROR
3002
- ]);
3003
- var WAIT_TYPES = /* @__PURE__ */ new Set([
3004
- SignalType.AGENT_OUTPUT,
3005
- SignalType.CLARIFICATION,
3006
- SignalType.PERMISSION,
3007
- SignalType.ERROR,
3008
- SignalType.FINAL
3009
- ]);
3010
- var SCOPE_TERMINAL_TYPES = /* @__PURE__ */ new Set([
3011
- SignalType.FINAL,
3012
- SignalType.ERROR,
3013
- SignalType.CLARIFICATION,
3014
- SignalType.PERMISSION
3015
- ]);
3016
- var PATHWAY_TYPES = new Set(
3017
- Object.values(SignalType).filter(
3018
- (t) => t !== SignalType.TASK && t !== SignalType.REGISTER && t !== SignalType.DEREGISTER && t !== SignalType.HEARTBEAT && t !== SignalType.DISCOVER
3019
- )
3020
- );
3021
- var PathwayClosedError = class extends Error {
3022
- constructor(message) {
3023
- super(message);
3024
- this.name = "PathwayClosedError";
3025
- }
3026
- };
3027
- var Pathway = class {
3028
- traceId;
3029
- parentId;
3030
- role;
3031
- scope;
3032
- scopeFilter;
3033
- onCloseHook;
3034
- handlers = /* @__PURE__ */ new Map();
3035
- waiters = [];
3036
- buffered = [];
3037
- closed_ = false;
3038
- // Async iteration: a pull queue of pending `next()` resolvers and a push
3039
- // queue of undelivered values. `null` is the close sentinel.
3040
- iterPush = [];
3041
- iterPull = [];
3042
- constructor(opts) {
3043
- const scope = opts.scope ?? "all";
3044
- if (scope !== "all" && scope !== "terminal") {
3045
- throw new Error(`scope must be 'all' or 'terminal', got '${scope}'`);
3046
- }
3047
- this.traceId = opts.traceId;
3048
- this.parentId = opts.parentId ?? null;
3049
- this.role = opts.role ?? "originator";
3050
- this.scope = scope;
3051
- this.scopeFilter = scope === "terminal" ? SCOPE_TERMINAL_TYPES : null;
3052
- this.onCloseHook = opts.onClose;
3053
- }
3054
- get closed() {
3055
- return this.closed_;
3056
- }
3057
- // -- consumer shape #1: wait ---------------------------------------
3058
- /** Resolve on the next AGENT_OUTPUT, CLARIFICATION, PERMISSION, ERROR or
3059
- * FINAL. Rejects with PathwayClosedError if the Pathway closes first, and
3060
- * with a TimeoutError-named Error if `timeoutMs` elapses. */
3061
- async wait(timeoutMs) {
3062
- return this.waitForTypes(WAIT_TYPES, timeoutMs);
3063
- }
3064
- /** Resolve on the next Signal of the given type. */
3065
- async waitFor(type, timeoutMs) {
3066
- return this.waitForTypes(/* @__PURE__ */ new Set([type]), timeoutMs);
3067
- }
3068
- async waitForTypes(types, timeoutMs) {
3069
- for (let i = 0; i < this.buffered.length; i++) {
3070
- const sig = this.buffered[i];
3071
- if (types.has(sig.type)) {
3072
- this.buffered.splice(i, 1);
3073
- return sig;
3074
- }
3075
- }
3076
- if (this.closed_) {
3077
- throw new PathwayClosedError(`Pathway for trace '${this.traceId}' is closed`);
3078
- }
3079
- return new Promise((resolve, reject) => {
3080
- const waiter = { types, resolve, reject, settled: false };
3081
- let timer = null;
3082
- const settle = (fn) => (a) => {
3083
- if (waiter.settled) return;
3084
- waiter.settled = true;
3085
- if (timer !== null) clearTimeout(timer);
3086
- this.waiters = this.waiters.filter((w) => w !== waiter);
3087
- fn(a);
3088
- };
3089
- waiter.resolve = settle(resolve);
3090
- waiter.reject = settle(reject);
3091
- if (timeoutMs !== void 0) {
3092
- timer = setTimeout(() => {
3093
- const err = new Error(
3094
- `Pathway.wait timed out after ${timeoutMs}ms on trace '${this.traceId}'`
3095
- );
3096
- err.name = "TimeoutError";
3097
- waiter.reject(err);
3098
- }, timeoutMs);
3099
- }
3100
- this.waiters.push(waiter);
3101
- });
3102
- }
3103
- // -- consumer shape #2: callbacks ----------------------------------
3104
- /** Register a callback fired for each Signal of the given type. */
3105
- on(type, fn) {
3106
- const list = this.handlers.get(type) ?? [];
3107
- list.push(fn);
3108
- this.handlers.set(type, list);
3109
- return fn;
3110
- }
3111
- // -- consumer shape #3: async iteration ----------------------------
3112
- [Symbol.asyncIterator]() {
3113
- return {
3114
- next: () => {
3115
- if (this.iterPush.length > 0) {
3116
- const v = this.iterPush.shift();
3117
- return Promise.resolve(
3118
- v === null ? { value: void 0, done: true } : { value: v, done: false }
3119
- );
3120
- }
3121
- if (this.closed_) {
3122
- return Promise.resolve({ value: void 0, done: true });
3123
- }
3124
- return new Promise((resolve) => this.iterPull.push(resolve));
3125
- }
3126
- };
3127
- }
3128
- iterEmit(v) {
3129
- const pull = this.iterPull.shift();
3130
- if (pull) {
3131
- pull(v === null ? { value: void 0, done: true } : { value: v, done: false });
3132
- } else {
3133
- this.iterPush.push(v);
3134
- }
3135
- }
3136
- // -- lifecycle ------------------------------------------------------
3137
- /** Close the Pathway. Idempotent. Pending waits reject with
3138
- * PathwayClosedError; iteration completes; the onClose hook fires once. */
3139
- async close() {
3140
- if (this.closed_) return;
3141
- this.closed_ = true;
3142
- for (const w of [...this.waiters]) {
3143
- w.reject(
3144
- new PathwayClosedError(
3145
- `Pathway for trace '${this.traceId}' closed before a matching Signal arrived`
3146
- )
3147
- );
3148
- }
3149
- this.waiters = [];
3150
- this.iterEmit(null);
3151
- if (this.onCloseHook) {
3152
- try {
3153
- await this.onCloseHook(this);
3154
- } catch {
3155
- }
3156
- }
3157
- }
3158
- /** `await using pathway = ...` support. */
3159
- async [Symbol.asyncDispose]() {
3160
- await this.close();
3161
- }
3162
- // -- internal: signal delivery (called by the owning Dendrite) ------
3163
- /** @internal */
3164
- async _deliver(signal) {
3165
- if (this.closed_) return;
3166
- if (this.scopeFilter !== null && !this.scopeFilter.has(signal.type)) {
3167
- await this.fireHandlers(signal);
3168
- if (TERMINAL_TYPES.has(signal.type)) await this.close();
3169
- return;
3170
- }
3171
- let consumed = false;
3172
- for (const w of [...this.waiters]) {
3173
- if (w.types.has(signal.type)) {
3174
- w.resolve(signal);
3175
- consumed = true;
3176
- }
3177
- }
3178
- if (!consumed) this.buffered.push(signal);
3179
- await this.fireHandlers(signal);
3180
- this.iterEmit(signal);
3181
- if (TERMINAL_TYPES.has(signal.type)) await this.close();
3182
- }
3183
- async fireHandlers(signal) {
3184
- for (const h of this.handlers.get(signal.type) ?? []) {
3185
- try {
3186
- await h(signal);
3187
- } catch {
3188
- }
3189
- }
3190
- }
3191
- };
3192
-
3193
3305
  // src/engram-client.ts
3194
3306
  function deferred() {
3195
3307
  let resolve;
@@ -3449,6 +3561,11 @@ var Dendrite = class _Dendrite {
3449
3561
  /** Hosted Engrams keyed by engramId, plus a kind index so RECALL/IMPRINT
3450
3562
  * addressed by engramKind reach every matching host. */
3451
3563
  _engrams = /* @__PURE__ */ new Map();
3564
+ // In-flight neuron work keyed by trace_id so a STOP can abandon exactly
3565
+ // one workflow. JS can't force-kill a running async body, so abort means
3566
+ // 'stop awaiting + suppress the reply'; the neuron should also check the
3567
+ // AbortSignal cooperatively where it can.
3568
+ traceAborts = /* @__PURE__ */ new Map();
3452
3569
  engramKindIndex = /* @__PURE__ */ new Map();
3453
3570
  /** Engrams learned from peer REGISTER signals (possibly out-of-process). */
3454
3571
  _engramRegistrations = /* @__PURE__ */ new Map();
@@ -3902,6 +4019,7 @@ var Dendrite = class _Dendrite {
3902
4019
  }
3903
4020
  await this.ensureInboundSub(SignalType.TASK_AWARDED);
3904
4021
  await this.ensureInboundSub(SignalType.DISCOVER);
4022
+ await this.ensureInboundSub(SignalType.STOP);
3905
4023
  if (this.autoBid) await this.ensureInboundSub(SignalType.TASK_OFFER);
3906
4024
  for (const axon of this._axons.values()) {
3907
4025
  await this.mirrorToStore(axon, "registered");
@@ -3917,6 +4035,8 @@ var Dendrite = class _Dendrite {
3917
4035
  }
3918
4036
  await this.ensureInboundSub(SignalType.RECALL);
3919
4037
  await this.ensureInboundSub(SignalType.IMPRINT);
4038
+ await this.ensureInboundSub(SignalType.FINAL);
4039
+ await this.ensureInboundSub(SignalType.ERROR);
3920
4040
  await this.ensureInboundSub(SignalType.REGISTER);
3921
4041
  for (const engram of this._engrams.values()) {
3922
4042
  try {
@@ -3935,6 +4055,7 @@ var Dendrite = class _Dendrite {
3935
4055
  await this.ensureInboundSub(t);
3936
4056
  }
3937
4057
  }
4058
+ await this.ensureInboundSub(SignalType.STOP);
3938
4059
  this.running = true;
3939
4060
  if (this._axons.size > 0 && this.heartbeatMs > 0) {
3940
4061
  this.startHeartbeatLoop();
@@ -4162,7 +4283,10 @@ var Dendrite = class _Dendrite {
4162
4283
  * Pathway, return the Signal. Use `scope: "terminal"` to wait only for
4163
4284
  * FINAL / ERROR / CLARIFICATION / PERMISSION. */
4164
4285
  async dispatchAndWait(args) {
4165
- const { timeoutMs, ...rest } = args;
4286
+ const { timeoutMs, retry, ...rest } = args;
4287
+ if (retry) {
4288
+ return this.runWithRetry({ ...rest, retry, ...timeoutMs !== void 0 ? { timeoutMs } : {} });
4289
+ }
4166
4290
  const pathway = await this.dispatch(rest);
4167
4291
  try {
4168
4292
  return await pathway.wait(timeoutMs ?? 3e4);
@@ -4761,9 +4885,11 @@ var Dendrite = class _Dendrite {
4761
4885
  if (!axon) return;
4762
4886
  target = axon.neuronId;
4763
4887
  }
4888
+ const ac = new AbortController();
4889
+ this.registerTraceAbort(task.trace_id, ac);
4764
4890
  let reply2;
4765
4891
  try {
4766
- reply2 = await axon.handleTask(task);
4892
+ reply2 = await this.raceAbort(axon.handleTask(task), ac.signal);
4767
4893
  } catch (err) {
4768
4894
  reply2 = errorSignal({
4769
4895
  traceId: task.trace_id,
@@ -4773,6 +4899,11 @@ var Dendrite = class _Dendrite {
4773
4899
  message: err instanceof Error ? err.message : String(err),
4774
4900
  recoverable: false
4775
4901
  });
4902
+ } finally {
4903
+ this.unregisterTraceAbort(task.trace_id, ac);
4904
+ }
4905
+ if (reply2 === null) {
4906
+ return;
4776
4907
  }
4777
4908
  await this.publish(reply2);
4778
4909
  if (reply2.type === SignalType.AGENT_OUTPUT && task.payload["finalize"]) {
@@ -4789,6 +4920,200 @@ var Dendrite = class _Dendrite {
4789
4920
  }
4790
4921
  }
4791
4922
  }
4923
+ // -- workflow control: STOP / STOPPED -------------------------------
4924
+ registerTraceAbort(traceId, ac) {
4925
+ let set = this.traceAborts.get(traceId);
4926
+ if (!set) {
4927
+ set = /* @__PURE__ */ new Set();
4928
+ this.traceAborts.set(traceId, set);
4929
+ }
4930
+ set.add(ac);
4931
+ }
4932
+ unregisterTraceAbort(traceId, ac) {
4933
+ const set = this.traceAborts.get(traceId);
4934
+ if (set) {
4935
+ set.delete(ac);
4936
+ if (set.size === 0) this.traceAborts.delete(traceId);
4937
+ }
4938
+ }
4939
+ /** Resolve to the promise's value, or to null if the signal aborts first. */
4940
+ raceAbort(p, signal) {
4941
+ if (signal.aborted) return Promise.resolve(null);
4942
+ return new Promise((resolve, reject) => {
4943
+ const onAbort = () => resolve(null);
4944
+ signal.addEventListener("abort", onAbort, { once: true });
4945
+ p.then(
4946
+ (v) => {
4947
+ signal.removeEventListener("abort", onAbort);
4948
+ resolve(v);
4949
+ },
4950
+ (e) => {
4951
+ signal.removeEventListener("abort", onAbort);
4952
+ reject(e);
4953
+ }
4954
+ );
4955
+ });
4956
+ }
4957
+ async onStop(signal) {
4958
+ const traceId = signal.trace_id;
4959
+ if (!traceId) return;
4960
+ const rollback = Boolean(signal.payload["rollback"]);
4961
+ let cancelled = 0;
4962
+ let compensated = 0;
4963
+ let didWork = false;
4964
+ const acs = this.traceAborts.get(traceId);
4965
+ if (acs) {
4966
+ for (const ac of acs) {
4967
+ if (!ac.signal.aborted) {
4968
+ ac.abort();
4969
+ cancelled++;
4970
+ }
4971
+ }
4972
+ this.traceAborts.delete(traceId);
4973
+ didWork = true;
4974
+ }
4975
+ try {
4976
+ await this.cancelOpPathways(traceId);
4977
+ this.engramClient.cancelTrace(traceId);
4978
+ } catch {
4979
+ }
4980
+ for (const engram of this._engrams.values()) {
4981
+ try {
4982
+ if (rollback) {
4983
+ const n = await engram.compensate(traceId);
4984
+ if (n > 0) {
4985
+ compensated += n;
4986
+ didWork = true;
4987
+ }
4988
+ } else {
4989
+ await engram.commit(traceId);
4990
+ }
4991
+ } catch {
4992
+ }
4993
+ }
4994
+ const pw = this.pathways.get(traceId);
4995
+ if (pw && !pw.closed) {
4996
+ didWork = true;
4997
+ try {
4998
+ await pw.close();
4999
+ } catch {
5000
+ }
5001
+ }
5002
+ if (didWork) {
5003
+ try {
5004
+ await this.publish(
5005
+ stoppedSignal({
5006
+ traceId,
5007
+ parentId: signal.id,
5008
+ node: this.namespace,
5009
+ rolledBack: rollback,
5010
+ cancelled,
5011
+ compensated
5012
+ })
5013
+ );
5014
+ } catch {
5015
+ }
5016
+ }
5017
+ }
5018
+ /** Broadcast a STOP for `traceId` (orchestrator-gated). Best-effort and
5019
+ * idempotent. */
5020
+ async emitStop(args) {
5021
+ this.requireOrchestrator("emitStop");
5022
+ await this.ensureInboundSub(SignalType.STOP);
5023
+ const sig = stopSignal({
5024
+ traceId: args.traceId,
5025
+ ...args.rollback !== void 0 ? { rollback: args.rollback } : {},
5026
+ ...args.reason !== void 0 ? { reason: args.reason } : {}
5027
+ });
5028
+ await this.publish(sig);
5029
+ return sig;
5030
+ }
5031
+ /** Stop a whole workflow. With `collectAcks` returns the STOPPED acks seen
5032
+ * within `timeoutMs` (best effort). */
5033
+ async stopTrace(traceId, opts = {}) {
5034
+ if (!opts.collectAcks) {
5035
+ await this.emitStop({
5036
+ traceId,
5037
+ ...opts.rollback !== void 0 ? { rollback: opts.rollback } : {},
5038
+ ...opts.reason !== void 0 ? { reason: opts.reason } : {}
5039
+ });
5040
+ return [];
5041
+ }
5042
+ const acks = [];
5043
+ const collect = async (sig) => {
5044
+ if (sig.trace_id === traceId) acks.push(sig);
5045
+ };
5046
+ const list = this.handlers.get(SignalType.STOPPED) ?? [];
5047
+ list.push(collect);
5048
+ this.handlers.set(SignalType.STOPPED, list);
5049
+ await this.ensureInboundSub(SignalType.STOPPED);
5050
+ try {
5051
+ await this.emitStop({
5052
+ traceId,
5053
+ ...opts.rollback !== void 0 ? { rollback: opts.rollback } : {},
5054
+ ...opts.reason !== void 0 ? { reason: opts.reason } : {}
5055
+ });
5056
+ await new Promise((r) => setTimeout(r, opts.timeoutMs ?? 1e3));
5057
+ } finally {
5058
+ const idx = (this.handlers.get(SignalType.STOPPED) ?? []).indexOf(collect);
5059
+ if (idx >= 0) (this.handlers.get(SignalType.STOPPED) ?? []).splice(idx, 1);
5060
+ }
5061
+ return acks;
5062
+ }
5063
+ // -- retry ----------------------------------------------------------
5064
+ async safeStop(traceId, retry) {
5065
+ try {
5066
+ await this.emitStop({
5067
+ traceId,
5068
+ rollback: Boolean(retry.rollbackOnRetry),
5069
+ reason: retry.reason ?? "retry"
5070
+ });
5071
+ } catch {
5072
+ }
5073
+ }
5074
+ /** Dispatch and wait, retrying per `retry` until a non-retryable outcome or
5075
+ * attempts are exhausted. Returns the resolved Signal; re-throws the last
5076
+ * error when every attempt failed with an exception. */
5077
+ async runWithRetry(args) {
5078
+ const { retry, timeoutMs, traceId: callerTrace, ...rest } = args;
5079
+ const maxAttempts = retry.maxAttempts ?? 3;
5080
+ const retryOn = retry.retryOn ?? defaultRetryOn;
5081
+ const newTrace = retry.newTrace ?? true;
5082
+ const perTimeout = retry.timeoutMs ?? timeoutMs ?? 3e4;
5083
+ let outcome = null;
5084
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
5085
+ const tid = callerTrace && !newTrace ? callerTrace : newTraceId();
5086
+ const meta = { ...rest.meta ?? {}, attempt };
5087
+ try {
5088
+ const pathway = await this.dispatch({ ...rest, traceId: tid, meta });
5089
+ try {
5090
+ outcome = await pathway.wait(perTimeout);
5091
+ } finally {
5092
+ await pathway.close();
5093
+ }
5094
+ } catch (err) {
5095
+ outcome = err instanceof Error ? err : new Error(String(err));
5096
+ }
5097
+ if (!retryOn(outcome)) {
5098
+ if (outcome instanceof Error) throw outcome;
5099
+ return outcome;
5100
+ }
5101
+ if (newTrace) await this.safeStop(tid, retry);
5102
+ if (attempt + 1 >= maxAttempts) {
5103
+ if (outcome instanceof Error) throw outcome;
5104
+ return outcome;
5105
+ }
5106
+ if (retry.onRetry) {
5107
+ try {
5108
+ retry.onRetry(attempt, outcome);
5109
+ } catch {
5110
+ }
5111
+ }
5112
+ const delay = retry.backoffMs ? retry.backoffMs(attempt) : 0;
5113
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
5114
+ }
5115
+ throw new Error("runWithRetry: exhausted attempts unexpectedly");
5116
+ }
4792
5117
  async emitRegister(axon) {
4793
5118
  await this.publish(
4794
5119
  registerSignal({
@@ -4912,6 +5237,21 @@ var Dendrite = class _Dendrite {
4912
5237
  if (hs.length) await Promise.allSettled(hs.map((h) => h(signal)));
4913
5238
  return;
4914
5239
  }
5240
+ if (signal.type === SignalType.STOP) {
5241
+ if (signal.trace_id) {
5242
+ const pw = this.pathways.get(signal.trace_id);
5243
+ if (pw) {
5244
+ try {
5245
+ await pw._deliver(signal);
5246
+ } catch {
5247
+ }
5248
+ }
5249
+ }
5250
+ await this.onStop(signal);
5251
+ const hs = this.handlers.get(SignalType.STOP) ?? [];
5252
+ if (hs.length) await Promise.allSettled(hs.map((h) => h(signal)));
5253
+ return;
5254
+ }
4915
5255
  if (signal.type === SignalType.RECALLED || signal.type === SignalType.IMPRINTED) {
4916
5256
  this.engramClient.deliver(signal);
4917
5257
  }
@@ -4938,6 +5278,13 @@ var Dendrite = class _Dendrite {
4938
5278
  if ((signal.type === SignalType.FINAL || signal.type === SignalType.ERROR) && signal.trace_id) {
4939
5279
  await this.cancelOpPathways(signal.trace_id);
4940
5280
  this.engramClient.cancelTrace(signal.trace_id);
5281
+ for (const engram of this._engrams.values()) {
5282
+ try {
5283
+ await engram.commit(signal.trace_id);
5284
+ } catch {
5285
+ }
5286
+ }
5287
+ this.traceAborts.delete(signal.trace_id);
4941
5288
  }
4942
5289
  if (signal.type === SignalType.TASK_AWARDED) {
4943
5290
  const target = signal.directed?.id ?? null;
@@ -5038,6 +5385,7 @@ var Dendrite = class _Dendrite {
5038
5385
  try {
5039
5386
  const receipt2 = await engram.imprint(op, entry, {
5040
5387
  imprintId: signal.id,
5388
+ traceId: signal.trace_id,
5041
5389
  ...mergeKey !== void 0 ? { mergeKey } : {}
5042
5390
  });
5043
5391
  reply2 = imprintedSignal({
@@ -5725,7 +6073,7 @@ var PostgresEngram = class extends Engram {
5725
6073
  };
5726
6074
 
5727
6075
  // src/index.ts
5728
- var VERSION = true ? "0.1.3" : "0.0.0-dev";
6076
+ var VERSION = true ? "0.1.4" : "0.0.0-dev";
5729
6077
  export {
5730
6078
  AXON_TYPES,
5731
6079
  Axon,
@@ -5775,6 +6123,7 @@ export {
5775
6123
  critiqueSignal,
5776
6124
  decode,
5777
6125
  deepMerge,
6126
+ defaultRetryOn,
5778
6127
  deregisterSignal,
5779
6128
  directedTo,
5780
6129
  discoverSignal,
@@ -5813,6 +6162,8 @@ export {
5813
6162
  reply,
5814
6163
  runWithTraceContext,
5815
6164
  standardMcpServers,
6165
+ stopSignal,
6166
+ stoppedSignal,
5816
6167
  synapseFromUrl,
5817
6168
  taskAwardedSignal,
5818
6169
  taskDeclinedSignal,