macp-sdk-typescript 0.10.0 → 0.12.0

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 (50) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/README.md +2 -2
  3. package/dist/agent/index.d.ts +1 -1
  4. package/dist/agent/index.js +2 -1
  5. package/dist/agent/participant.d.ts +47 -4
  6. package/dist/agent/participant.js +88 -10
  7. package/dist/agent/strategies.js +23 -7
  8. package/dist/agent/transports.d.ts +19 -1
  9. package/dist/agent/transports.js +108 -66
  10. package/dist/auth.d.ts +15 -4
  11. package/dist/auth.js +23 -7
  12. package/dist/base-session.js +13 -3
  13. package/dist/client.js +58 -15
  14. package/dist/commitment-hash.d.ts +33 -3
  15. package/dist/commitment-hash.js +39 -5
  16. package/dist/decision.d.ts +1 -0
  17. package/dist/decision.js +11 -9
  18. package/dist/envelope.js +9 -1
  19. package/dist/handoff.d.ts +1 -0
  20. package/dist/handoff.js +19 -6
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +18 -7
  23. package/dist/policy.d.ts +54 -7
  24. package/dist/policy.js +143 -19
  25. package/dist/projections/base.d.ts +71 -14
  26. package/dist/projections/base.js +106 -16
  27. package/dist/projections/decision.d.ts +5 -49
  28. package/dist/projections/decision.js +30 -85
  29. package/dist/projections/handoff.d.ts +5 -48
  30. package/dist/projections/handoff.js +44 -93
  31. package/dist/projections/proposal.d.ts +5 -48
  32. package/dist/projections/proposal.js +21 -79
  33. package/dist/projections/quorum.d.ts +5 -49
  34. package/dist/projections/quorum.js +20 -82
  35. package/dist/projections/task.d.ts +31 -51
  36. package/dist/projections/task.js +49 -90
  37. package/dist/proposal.d.ts +1 -0
  38. package/dist/proposal.js +2 -2
  39. package/dist/proto-registry.d.ts +85 -5
  40. package/dist/proto-registry.js +101 -15
  41. package/dist/quorum.d.ts +1 -0
  42. package/dist/quorum.js +4 -4
  43. package/dist/task.d.ts +1 -0
  44. package/dist/task.js +24 -11
  45. package/dist/types.d.ts +14 -7
  46. package/dist/validation.d.ts +14 -0
  47. package/dist/validation.js +39 -5
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +6 -5
@@ -29,7 +29,23 @@ class Participant {
29
29
  modeVersion;
30
30
  configurationVersion;
31
31
  policyVersion;
32
+ /** Whether the event loop is currently inside {@link run}. */
32
33
  running = false;
34
+ /**
35
+ * Latching: set once a terminal result is dispatched (a terminal phase,
36
+ * or the {@code SessionCancel} fallback in {@link processMessage}). A
37
+ * terminal `Participant` is single-use — {@link run} refuses to
38
+ * re-enter, because the projection is resolved and the runtime will
39
+ * accept nothing further for this session.
40
+ */
41
+ terminal = false;
42
+ /**
43
+ * The cooperative cancel flag {@link stop} sets and {@link run} clears
44
+ * on entry — the loop's break predicate. Distinct from {@link terminal}:
45
+ * a `stop()`-exited, non-terminal `Participant` is a resumable pause
46
+ * (see {@link isStopped}), not single-use.
47
+ */
48
+ stopRequested = false;
33
49
  lastPhase;
34
50
  cancelCallbackServer;
35
51
  cancelCallbackConfig;
@@ -170,13 +186,19 @@ class Participant {
170
186
  this.dispatcher.onTerminal(handler);
171
187
  return this;
172
188
  }
173
- /** Whether {@link stop} has been called (or the loop has exited). */
189
+ /**
190
+ * `true` after a terminal outcome (single-use from here on), or while a
191
+ * `stop()` request is pending/has taken effect on a `Participant` whose
192
+ * loop has not (yet) been resumed by a later {@link run}. A `stop()`
193
+ * exit is resumable — see {@link run} and {@link terminal}'s own doc.
194
+ */
174
195
  get isStopped() {
175
- return !this.running;
196
+ return this.terminal || this.stopRequested;
176
197
  }
177
198
  async run() {
178
- if (this.running)
199
+ if (this.running || this.terminal)
179
200
  return;
201
+ this.stopRequested = false;
180
202
  this.running = true;
181
203
  if (this.cancelCallbackConfig && !this.cancelCallbackServer) {
182
204
  this.cancelCallbackServer = await (0, cancel_callback_1.startCancelCallbackServer)({
@@ -193,7 +215,7 @@ class Participant {
193
215
  }
194
216
  try {
195
217
  for await (const message of this.transport.start()) {
196
- if (!this.running)
218
+ if (this.stopRequested)
197
219
  break;
198
220
  const reachedTerminal = await this.processMessage(message);
199
221
  if (reachedTerminal)
@@ -202,6 +224,7 @@ class Participant {
202
224
  }
203
225
  finally {
204
226
  this.running = false;
227
+ await this.teardown();
205
228
  }
206
229
  }
207
230
  /**
@@ -244,6 +267,7 @@ class Participant {
244
267
  /** Returns true if a terminal state was reached. */
245
268
  async processMessage(message) {
246
269
  const ctx = this.buildHandlerContext();
270
+ const alreadyTerminal = this.terminal;
247
271
  if (this.session && message.raw) {
248
272
  const applyMethod = this.session.projection
249
273
  .applyEnvelope;
@@ -252,24 +276,64 @@ class Participant {
252
276
  }
253
277
  }
254
278
  await this.dispatcher.dispatch(message, ctx);
279
+ let firedTerminal = false;
255
280
  const currentPhase = this.projection.phase;
256
281
  if (currentPhase !== this.lastPhase) {
257
282
  this.lastPhase = currentPhase;
258
283
  await this.dispatcher.dispatchPhaseChange(currentPhase, ctx);
259
284
  if (TERMINAL_PHASES.has(currentPhase)) {
285
+ this.terminal = true;
286
+ firedTerminal = true;
260
287
  const terminalResult = {
261
288
  state: currentPhase,
262
289
  commitment: this.projection.commitment,
263
290
  };
264
291
  await this.dispatcher.dispatchTerminal(terminalResult);
265
- return true;
266
292
  }
267
293
  }
268
- return false;
294
+ // Fallback for envelopes no projection maps to a phase (issue #106.1):
295
+ // no built-in projection maps `SessionCancel` to a terminal phase, so the
296
+ // phase-driven path above never fires for it. Against the current
297
+ // macp-runtime, `run()`'s streamed path never even observes this branch —
298
+ // `cancel_session` stores `SessionCancel` as `EntryKind::Internal`
299
+ // (runtime.rs `make_internal_entry`/`cancel_session`), and
300
+ // `get_incoming_after` (macp-storage `log_store.rs`) filters strictly to
301
+ // `EntryKind::Incoming`, so `StreamSession`/`GrpcTransportAdapter` never
302
+ // delivers it; cancellation surfaces via the separate
303
+ // `session_lifecycle_bus`/`WatchSessions` mechanism instead. This
304
+ // fallback's real, currently-reachable value is `processEvent()` callers
305
+ // driving their own foreign message loop (where a `SessionCancel` can be
306
+ // handed in directly) and cross-SDK parity with `macp-sdk-python`'s
307
+ // identical fallback — not the streamed `run()` path today. Gated on
308
+ // `!alreadyTerminal` (captured before this call's own phase-driven
309
+ // branch could have just set it) so a `SessionCancel` arriving on an
310
+ // already-terminal session — one whose own terminal dispatch already
311
+ // ran, this call or an earlier one — cannot double-dispatch
312
+ // `onTerminal`.
313
+ if (!firedTerminal && !alreadyTerminal && message.messageType === 'SessionCancel') {
314
+ this.terminal = true;
315
+ this.lastPhase = 'Cancelled';
316
+ await this.dispatcher.dispatchTerminal({ state: 'Cancelled' });
317
+ return true;
318
+ }
319
+ return firedTerminal;
269
320
  }
270
- async stop() {
271
- this.running = false;
272
- await this.transport.stop();
321
+ /**
322
+ * Idempotent teardown of everything {@link run} started: the transport
323
+ * subscription and the cancel-callback HTTP listener, if any. Called
324
+ * from both {@link stop} and every exit from {@link run}'s loop (issue
325
+ * #106.2) — a normal terminal exit or a transport running out of
326
+ * messages used to leak both. A rejecting `transport.stop()` is caught
327
+ * rather than propagated, so a transport fault on teardown cannot mask
328
+ * a clean session outcome as a thrown error from {@link run}.
329
+ */
330
+ async teardown() {
331
+ try {
332
+ await this.transport.stop();
333
+ }
334
+ catch (err) {
335
+ logging_1.logger.debug('transport stop failed', err);
336
+ }
273
337
  if (this.cancelCallbackServer) {
274
338
  const srv = this.cancelCallbackServer;
275
339
  this.cancelCallbackServer = undefined;
@@ -281,9 +345,23 @@ class Participant {
281
345
  }
282
346
  }
283
347
  }
348
+ /**
349
+ * Request that {@link run}'s loop stop. This SDK's {@link TransportAdapter}
350
+ * has no `cancel()` — `stop()` on the transport is the only thing that can
351
+ * wake a loop suspended awaiting the next envelope, so teardown (including
352
+ * the transport) runs here unconditionally, not just the flag. Safe to
353
+ * call whether or not `run()` is currently active; does not itself flip
354
+ * {@link running} — only `run()`'s own loop exit does that, since it is
355
+ * the only place that actually knows the loop has left.
356
+ */
357
+ async stop() {
358
+ this.stopRequested = true;
359
+ await this.teardown();
360
+ }
284
361
  /**
285
362
  * Attach a running cancel-callback HTTP server to this participant.
286
- * The server is closed automatically when {@link stop} is called.
363
+ * The server is closed automatically on any exit from {@link run} — see
364
+ * {@link teardown}.
287
365
  * Parity with python-sdk's `Participant.attach_cancel_callback_server`.
288
366
  */
289
367
  attachCancelCallbackServer(server) {
@@ -9,15 +9,23 @@ exports.commitmentHandler = commitmentHandler;
9
9
  exports.functionCommitter = functionCommitter;
10
10
  exports.majorityCommitter = majorityCommitter;
11
11
  const envelope_1 = require("../envelope");
12
+ const validation_1 = require("../validation");
12
13
  function evaluationHandler(strategy) {
13
14
  return async (event, ctx) => {
14
15
  if (event.messageType !== 'Proposal')
15
16
  return;
16
17
  const result = await strategy.evaluate(event.payload, ctx.session);
18
+ // Issue #108.7/4g: validate the strategy's own output — reusing this
19
+ // repo's own validators (they throw MacpSessionError, the documented
20
+ // error contract) rather than porting Python's inline ValueError checks
21
+ // — and dispatch the normalized value, so a bad strategy fails loudly at
22
+ // the handler naming the strategy, not later from the session.
23
+ const recommendation = (0, validation_1.validateRecommendation)(result.recommendation);
24
+ (0, validation_1.validateConfidence)(result.confidence);
17
25
  if (ctx.actions.evaluate) {
18
26
  await ctx.actions.evaluate({
19
27
  proposalId: event.proposalId ?? event.payload.proposalId ?? event.payload.proposal_id,
20
- recommendation: result.recommendation,
28
+ recommendation,
21
29
  confidence: result.confidence,
22
30
  reason: result.reason,
23
31
  });
@@ -35,11 +43,12 @@ function votingHandler(strategy) {
35
43
  if (!strategy.shouldVote(projection))
36
44
  return;
37
45
  const result = await strategy.decideVote(projection);
46
+ const vote = (0, validation_1.validateVote)(result.vote);
38
47
  if (ctx.actions.vote) {
39
48
  const proposalId = event.proposalId ?? event.payload.proposalId ?? event.payload.proposal_id;
40
49
  await ctx.actions.vote({
41
50
  proposalId,
42
- vote: result.vote,
51
+ vote,
43
52
  reason: result.reason,
44
53
  });
45
54
  }
@@ -52,17 +61,24 @@ function majorityVoter(options) {
52
61
  const threshold = options?.positiveThreshold ?? 0.5;
53
62
  return {
54
63
  shouldVote(projection) {
55
- return projection.evaluations.length > 0;
64
+ // RFC-MACP-0007 §4 (`rfcs/RFC-MACP-0007-decision-mode.md:73`): REVIEW
65
+ // evaluations "do not block or approve a proposal; they serve as
66
+ // informational analysis records only." A set of only REVIEWs has no
67
+ // decisive evaluation to vote on -- mirroring RFC-MACP-0012:137's
68
+ // denominator rule ("the denominator is the decisive votes ...
69
+ // Abstentions are excluded") for votes (issue #124 item 2).
70
+ return projection.evaluations.some((e) => e.recommendation.toLowerCase() !== 'review');
56
71
  },
57
72
  async decideVote(projection) {
58
- const positive = projection.evaluations.filter((e) => ['approve', 'accept', 'yes'].includes(e.recommendation.toLowerCase()));
59
- const ratio = projection.evaluations.length > 0 ? positive.length / projection.evaluations.length : 0;
73
+ const decisive = projection.evaluations.filter((e) => e.recommendation.toLowerCase() !== 'review');
74
+ const positive = decisive.filter((e) => ['approve', 'accept', 'yes'].includes(e.recommendation.toLowerCase()));
75
+ const ratio = decisive.length > 0 ? positive.length / decisive.length : 0;
60
76
  if (ratio >= threshold) {
61
- return { vote: 'APPROVE', reason: `${positive.length}/${projection.evaluations.length} evaluations positive` };
77
+ return { vote: 'APPROVE', reason: `${positive.length}/${decisive.length} evaluations positive` };
62
78
  }
63
79
  return {
64
80
  vote: 'REJECT',
65
- reason: `Only ${positive.length}/${projection.evaluations.length} evaluations positive`,
81
+ reason: `Only ${positive.length}/${decisive.length} evaluations positive`,
66
82
  };
67
83
  },
68
84
  };
@@ -5,6 +5,23 @@ export interface TransportAdapter {
5
5
  start(): AsyncIterable<IncomingMessage>;
6
6
  stop(): Promise<void>;
7
7
  }
8
+ /**
9
+ * Bounded retry policy for the startup subscribe race (issue #100, ported
10
+ * from macp-sdk-python#75): the runtime replies `NOT_FOUND` to `StreamSession`/`sendSubscribe` when a
11
+ * sibling participant hasn't finished the initiator's SessionStart yet, so
12
+ * the session doesn't exist *yet* rather than not existing at all. Distinct
13
+ * from `src/retry.ts`'s `RetryPolicy`, which is scoped to retryable **ack**
14
+ * (NACK) codes on `Send` — this policy is a gRPC **stream**-open retry and
15
+ * has no `retryableCodes` set: the one retryable condition (transient
16
+ * `NOT_FOUND` before any envelope has been delivered) is hardcoded in
17
+ * `GrpcTransportAdapter.start()`, not configurable per-code.
18
+ */
19
+ export interface SubscribeRetryPolicy {
20
+ maxRetries: number;
21
+ backoffBase: number;
22
+ backoffMax: number;
23
+ }
24
+ export declare const DEFAULT_SUBSCRIBE_RETRY_POLICY: SubscribeRetryPolicy;
8
25
  export declare class GrpcTransportAdapter implements TransportAdapter {
9
26
  private readonly client;
10
27
  private readonly sessionId;
@@ -12,6 +29,7 @@ export declare class GrpcTransportAdapter implements TransportAdapter {
12
29
  private stream;
13
30
  private seq;
14
31
  private delivered;
32
+ private readonly subscribeRetryPolicy;
15
33
  /**
16
34
  * `message_id`s already counted into `delivered`, so the resume cursor
17
35
  * advances once per distinct accepted envelope rather than once per raw
@@ -28,7 +46,7 @@ export declare class GrpcTransportAdapter implements TransportAdapter {
28
46
  * TTL-bounded by protocol.
29
47
  */
30
48
  private readonly seenMessageIds;
31
- constructor(client: MacpClient, sessionId: string, auth?: AuthConfig | undefined);
49
+ constructor(client: MacpClient, sessionId: string, auth?: AuthConfig | undefined, subscribeRetryPolicy?: Partial<SubscribeRetryPolicy>);
32
50
  /**
33
51
  * The server passive-subscribe ordinal of the last delivered envelope (the
34
52
  * 1-based count of *distinct* accepted envelopes delivered on this stream,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HttpTransportAdapter = exports.GrpcTransportAdapter = void 0;
3
+ exports.HttpTransportAdapter = exports.GrpcTransportAdapter = exports.DEFAULT_SUBSCRIBE_RETRY_POLICY = void 0;
4
+ const errors_1 = require("../errors");
4
5
  function normalizeEnvelope(envelope, decodePayload, seq) {
5
6
  const payload = decodePayload(envelope.mode, envelope.messageType, envelope.payload) ?? {};
6
7
  return {
@@ -12,6 +13,14 @@ function normalizeEnvelope(envelope, decodePayload, seq) {
12
13
  seq,
13
14
  };
14
15
  }
16
+ exports.DEFAULT_SUBSCRIBE_RETRY_POLICY = {
17
+ maxRetries: 4,
18
+ backoffBase: 0.1,
19
+ backoffMax: 2.0,
20
+ };
21
+ function sleep(seconds) {
22
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
23
+ }
15
24
  class GrpcTransportAdapter {
16
25
  client;
17
26
  sessionId;
@@ -19,6 +28,7 @@ class GrpcTransportAdapter {
19
28
  stream = null;
20
29
  seq = 0;
21
30
  delivered = 0;
31
+ subscribeRetryPolicy;
22
32
  /**
23
33
  * `message_id`s already counted into `delivered`, so the resume cursor
24
34
  * advances once per distinct accepted envelope rather than once per raw
@@ -35,10 +45,11 @@ class GrpcTransportAdapter {
35
45
  * TTL-bounded by protocol.
36
46
  */
37
47
  seenMessageIds = new Set();
38
- constructor(client, sessionId, auth) {
48
+ constructor(client, sessionId, auth, subscribeRetryPolicy) {
39
49
  this.client = client;
40
50
  this.sessionId = sessionId;
41
51
  this.auth = auth;
52
+ this.subscribeRetryPolicy = { ...exports.DEFAULT_SUBSCRIBE_RETRY_POLICY, ...subscribeRetryPolicy };
42
53
  }
43
54
  /**
44
55
  * The server passive-subscribe ordinal of the last delivered envelope (the
@@ -61,72 +72,103 @@ class GrpcTransportAdapter {
61
72
  return this.delivered;
62
73
  }
63
74
  async *start() {
64
- // A prior stream from an earlier start() (without an intervening stop())
65
- // must not keep feeding this adapter's counter — two live generators
66
- // would both advance `delivered` from two differently-positioned
67
- // replays. `MacpStream.close()` is `this.call.end()` (`src/client.ts:223-227`),
68
- // a write-side half-close — it does not itself force the server to stop
69
- // pushing. It is enough here because macp-runtime's StreamSession loop
70
- // reads the resulting end-of-request-stream as `StreamAction::ClientDone`,
71
- // whose arm drains any already-buffered envelopes once and then breaks,
72
- // ending that stream's response side too
73
- // (macp-runtime/src/server.rs:654-666). Close it before opening the new
74
- // one so re-entrant start() and stop()-then-start() behave identically.
75
- if (this.stream) {
76
- this.stream.close();
77
- this.stream = null;
78
- }
79
- this.stream = this.client.openStream({ auth: this.auth });
80
- // RFC-MACP-0006 §3.2: subscribe to the session, resuming from this
81
- // adapter's own cursor. `this.delivered` is 0 before anything has been
82
- // delivered, by construction, and `afterSequence = 0` is normatively
83
- // "replay from the session's first accepted envelope" — so the first
84
- // subscribe and every later one (e.g. after stop()+start()) are the same
85
- // expression. There is no separate first-subscribe/reconnect branch to
86
- // keep in sync with the counter.
87
- await this.stream.sendSubscribe(this.sessionId, this.delivered);
88
- for await (const envelope of this.stream.responses()) {
89
- if (envelope.sessionId !== this.sessionId)
90
- continue;
91
- // Count this envelope into the resume cursor only *after* the
92
- // consumer has come back for more, not when it is handed off. `yield`
93
- // suspends here until the consumer's `for await` either calls
94
- // `.next()` again (it took this envelope and kept iterating — the
95
- // code below runs) or closes the iterator via `break`/`return`/throw
96
- // (e.g. `Participant.run()` observing `!this.running` and breaking
97
- // out of its loop without processing this envelope). In the latter
98
- // case `.return()` on this generator resumes at this `yield` as if a
99
- // `return` had been written here, so nothing below it runs: the
100
- // cursor is never advanced past an envelope the consumer never
101
- // actually took past the loop boundary, and the next `start()`
102
- // redelivers it instead of silently skipping it. See #66 — counting
103
- // before yielding let a stop-mid-stream permanently drop an envelope
104
- // once #65 made the resume cursor authoritative.
105
- const message = normalizeEnvelope(envelope, (mode, mt, p) => this.client.protoRegistry.decodeKnownPayload(mode, mt, p), this.seq++);
106
- yield message;
107
- // RFC-MACP-0006 §3.2 Redelivery: a redelivery MUST NOT advance the
108
- // resume cursor, and a consumer that accumulates state per envelope
109
- // MUST be idempotent w.r.t. `message_id`. An empty/absent messageId
110
- // has no identity to dedup on and increments unconditionally — the
111
- // same carve-out the projection guard documents at
112
- // `src/projections/base.ts:224-227`, so the two dedup sites read the
113
- // same way. In practice the `else` below can never run against a
114
- // conformant runtime: `validate_envelope_shape` rejects any envelope
115
- // with an empty `message_id` as `InvalidEnvelope` before it can be
116
- // accepted (`macp-runtime/src/server.rs:118`), and RFC-MACP-0001 §8.2
117
- // makes `message_id` the runtime's dedup identity, so no accepted
118
- // envelope in any session history can carry one. The branch stays
119
- // anyway — it costs nothing and errs toward still counting the
120
- // envelope instead of silently dropping it if a non-conformant server
121
- // ever sends one.
122
- if (envelope.messageId) {
123
- if (!this.seenMessageIds.has(envelope.messageId)) {
124
- this.seenMessageIds.add(envelope.messageId);
125
- this.delivered++;
75
+ // Startup subscribe race (issue #100, ported from macp-sdk-python#75): a
76
+ // sibling participant may not have observed the initiator's SessionStart
77
+ // yet, so the runtime's very first reply to sendSubscribe() can be a
78
+ // transient NOT_FOUND for a session that simply doesn't exist *yet* —
79
+ // not a real error. `receivedAny` seeds `true` when `this.delivered > 0`
80
+ // (a prior start() on this same adapter already proved the session
81
+ // exists), so a stop()+start() reconnect never re-enters the retry
82
+ // budget it doesn't need. Once any envelope for this session has been
83
+ // delivered in this call, the session demonstrably exists and a later
84
+ // NOT_FOUND (e.g. the session expired mid-stream) must propagate
85
+ // immediately, not retry.
86
+ let receivedAny = this.delivered > 0;
87
+ let attempt = 0;
88
+ while (true) {
89
+ // A prior stream from an earlier start() (without an intervening stop())
90
+ // must not keep feeding this adapter's counter — two live generators
91
+ // would both advance `delivered` from two differently-positioned
92
+ // replays. `MacpStream.close()` is `this.call.end()` (`src/client.ts:223-227`),
93
+ // a write-side half-close — it does not itself force the server to stop
94
+ // pushing. It is enough here because macp-runtime's StreamSession loop
95
+ // reads the resulting end-of-request-stream as `StreamAction::ClientDone`,
96
+ // whose arm drains any already-buffered envelopes once and then breaks,
97
+ // ending that stream's response side too
98
+ // (macp-runtime/src/server.rs:654-666). Close it before opening the new
99
+ // one so re-entrant start() and stop()-then-start() (including a
100
+ // subscribe-retry reconnect) behave identically.
101
+ if (this.stream) {
102
+ this.stream.close();
103
+ this.stream = null;
104
+ }
105
+ this.stream = this.client.openStream({ auth: this.auth });
106
+ // RFC-MACP-0006 §3.2: subscribe to the session, resuming from this
107
+ // adapter's own cursor. `this.delivered` is 0 before anything has been
108
+ // delivered, by construction, and `afterSequence = 0` is normatively
109
+ // "replay from the session's first accepted envelope" — so the first
110
+ // subscribe and every later one (e.g. after stop()+start(), or a
111
+ // subscribe retry below) are the same expression. There is no separate
112
+ // first-subscribe/reconnect branch to keep in sync with the counter.
113
+ await this.stream.sendSubscribe(this.sessionId, this.delivered);
114
+ try {
115
+ for await (const envelope of this.stream.responses()) {
116
+ if (envelope.sessionId !== this.sessionId)
117
+ continue;
118
+ receivedAny = true;
119
+ // Count this envelope into the resume cursor only *after* the
120
+ // consumer has come back for more, not when it is handed off. `yield`
121
+ // suspends here until the consumer's `for await` either calls
122
+ // `.next()` again (it took this envelope and kept iterating — the
123
+ // code below runs) or closes the iterator via `break`/`return`/throw
124
+ // (e.g. `Participant.run()` observing its `stopRequested` flag and
125
+ // breaking out of its loop without processing this envelope). In the latter
126
+ // case `.return()` on this generator resumes at this `yield` as if a
127
+ // `return` had been written here, so nothing below it runs: the
128
+ // cursor is never advanced past an envelope the consumer never
129
+ // actually took past the loop boundary, and the next `start()`
130
+ // redelivers it instead of silently skipping it. See #66 — counting
131
+ // before yielding let a stop-mid-stream permanently drop an envelope
132
+ // once #65 made the resume cursor authoritative.
133
+ const message = normalizeEnvelope(envelope, (mode, mt, p) => this.client.protoRegistry.decodeKnownPayload(mode, mt, p), this.seq++);
134
+ yield message;
135
+ // RFC-MACP-0006 §3.2 Redelivery: a redelivery MUST NOT advance the
136
+ // resume cursor, and a consumer that accumulates state per envelope
137
+ // MUST be idempotent w.r.t. `message_id`. An empty/absent messageId
138
+ // has no identity to dedup on and increments unconditionally — the
139
+ // same carve-out the projection guard documents at
140
+ // `src/projections/base.ts:224-227`, so the two dedup sites read the
141
+ // same way. In practice the `else` below can never run against a
142
+ // conformant runtime: `validate_envelope_shape` rejects any envelope
143
+ // with an empty `message_id` as `InvalidEnvelope` before it can be
144
+ // accepted (`macp-runtime/src/server.rs:118`), and RFC-MACP-0001 §8.2
145
+ // makes `message_id` the runtime's dedup identity, so no accepted
146
+ // envelope in any session history can carry one. The branch stays
147
+ // anyway — it costs nothing and errs toward still counting the
148
+ // envelope instead of silently dropping it if a non-conformant server
149
+ // ever sends one.
150
+ if (envelope.messageId) {
151
+ if (!this.seenMessageIds.has(envelope.messageId)) {
152
+ this.seenMessageIds.add(envelope.messageId);
153
+ this.delivered++;
154
+ }
155
+ }
156
+ else {
157
+ this.delivered++;
158
+ }
126
159
  }
160
+ return;
127
161
  }
128
- else {
129
- this.delivered++;
162
+ catch (err) {
163
+ const isTransientNotFound = !receivedAny &&
164
+ err instanceof errors_1.MacpTransportError &&
165
+ err.code === 'NOT_FOUND' &&
166
+ attempt < this.subscribeRetryPolicy.maxRetries;
167
+ if (!isTransientNotFound)
168
+ throw err;
169
+ attempt++;
170
+ const delay = Math.min(this.subscribeRetryPolicy.backoffBase * 2 ** (attempt - 1), this.subscribeRetryPolicy.backoffMax);
171
+ await sleep(delay);
130
172
  }
131
173
  }
132
174
  }
package/dist/auth.d.ts CHANGED
@@ -29,8 +29,15 @@ export declare const Auth: {
29
29
  * otherwise). Not for production — use {@link Auth.bearer}.
30
30
  *
31
31
  * The legacy `x-macp-agent-id` header was removed in 0.5.0: no supported
32
- * runtime reads it. Does not set {@link AuthConfig.expectedSender}; dev flows
33
- * stay permissive so tests can reuse a single credential across senders.
32
+ * runtime reads it. Sets {@link AuthConfig.expectedSender} to `agentId`
33
+ * (issue #124): the runtime already refuses a mismatched `sender`
34
+ * unconditionally for dev credentials (`macp-runtime/src/server.rs:229-231`,
35
+ * no insecure/dev exemption), so this SDK now enforces the same guarantee
36
+ * client-side instead of surfacing the runtime's rejection late and
37
+ * confusingly. A caller that genuinely wants the pre-0.2 permissive
38
+ * behavior — reusing one credential across multiple senders — should use
39
+ * `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead, which
40
+ * never sets `expectedSender`.
34
41
  */
35
42
  devAgent(agentId: string): AuthConfig;
36
43
  /**
@@ -50,8 +57,12 @@ export declare function validateAuth(auth: AuthConfig): void;
50
57
  export declare function authSender(auth?: AuthConfig): string | undefined;
51
58
  /**
52
59
  * Throw {@link MacpIdentityMismatchError} when a caller-supplied `sender`
53
- * conflicts with `auth.expectedSender`. Silent when either is undefined, so
54
- * dev credentials and legacy bearer usage retain pre-0.2 behavior.
60
+ * conflicts with `auth.expectedSender`. Silent when `auth.expectedSender` is
61
+ * undefined, or when `sender` is undefined or `''` — an empty `sender` is
62
+ * treated the same as absent, matching `macp-runtime/src/server.rs:229`'s
63
+ * `!env.sender.is_empty() && ...` guard (issue #124): the runtime always
64
+ * accepts an empty `sender`, so this client-side guard must too, or it would
65
+ * reject a payload the runtime would accept.
55
66
  */
56
67
  export declare function assertSenderMatchesIdentity(auth: AuthConfig | undefined, sender: string | undefined): void;
57
68
  export declare function metadataFromAuth(auth: AuthConfig): grpc.Metadata;
package/dist/auth.js CHANGED
@@ -50,11 +50,18 @@ exports.Auth = {
50
50
  * otherwise). Not for production — use {@link Auth.bearer}.
51
51
  *
52
52
  * The legacy `x-macp-agent-id` header was removed in 0.5.0: no supported
53
- * runtime reads it. Does not set {@link AuthConfig.expectedSender}; dev flows
54
- * stay permissive so tests can reuse a single credential across senders.
53
+ * runtime reads it. Sets {@link AuthConfig.expectedSender} to `agentId`
54
+ * (issue #124): the runtime already refuses a mismatched `sender`
55
+ * unconditionally for dev credentials (`macp-runtime/src/server.rs:229-231`,
56
+ * no insecure/dev exemption), so this SDK now enforces the same guarantee
57
+ * client-side instead of surfacing the runtime's rejection late and
58
+ * confusingly. A caller that genuinely wants the pre-0.2 permissive
59
+ * behavior — reusing one credential across multiple senders — should use
60
+ * `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead, which
61
+ * never sets `expectedSender`.
55
62
  */
56
63
  devAgent(agentId) {
57
- return { bearerToken: agentId, senderHint: agentId };
64
+ return { bearerToken: agentId, senderHint: agentId, expectedSender: agentId };
58
65
  },
59
66
  /**
60
67
  * Production bearer-token credential. Pass `{ expectedSender }` to have the
@@ -82,7 +89,12 @@ exports.Auth = {
82
89
  };
83
90
  function validateAuth(auth) {
84
91
  if (!auth.bearerToken) {
85
- throw new Error('bearerToken is required');
92
+ // MacpSdkError (issue #108.8), not a bare Error: the documented pattern
93
+ // in both SDKs' docs is `catch (e) { if (e instanceof MacpSdkError) ... }`,
94
+ // so a plain Error escaped it. MacpSdkError, not MacpSessionError — this
95
+ // is a client configuration fault, not a session-payload fault. Message
96
+ // text unchanged so any existing message-matching caller still matches.
97
+ throw new errors_1.MacpSdkError('bearerToken is required');
86
98
  }
87
99
  }
88
100
  function authSender(auth) {
@@ -92,13 +104,17 @@ function authSender(auth) {
92
104
  }
93
105
  /**
94
106
  * Throw {@link MacpIdentityMismatchError} when a caller-supplied `sender`
95
- * conflicts with `auth.expectedSender`. Silent when either is undefined, so
96
- * dev credentials and legacy bearer usage retain pre-0.2 behavior.
107
+ * conflicts with `auth.expectedSender`. Silent when `auth.expectedSender` is
108
+ * undefined, or when `sender` is undefined or `''` — an empty `sender` is
109
+ * treated the same as absent, matching `macp-runtime/src/server.rs:229`'s
110
+ * `!env.sender.is_empty() && ...` guard (issue #124): the runtime always
111
+ * accepts an empty `sender`, so this client-side guard must too, or it would
112
+ * reject a payload the runtime would accept.
97
113
  */
98
114
  function assertSenderMatchesIdentity(auth, sender) {
99
115
  if (!auth?.expectedSender)
100
116
  return;
101
- if (sender === undefined)
117
+ if (sender === undefined || sender === '')
102
118
  return;
103
119
  if (sender !== auth.expectedSender) {
104
120
  throw new errors_1.MacpIdentityMismatchError(auth.expectedSender, sender);
@@ -57,9 +57,19 @@ class BaseSession {
57
57
  }
58
58
  /** Send SessionStart and begin tracking via the projection. */
59
59
  async start(input) {
60
- (0, validation_1.validateParticipantCount)(input.participants.length);
61
- if (input.maxSuspendMs !== undefined)
62
- (0, validation_1.validateMaxSuspendMs)(input.maxSuspendMs);
60
+ // Bring this extension point's validation up to parity with the five
61
+ // built-in mode sessions (issue #108.4/4d): previously only participant
62
+ // count + maxSuspendMs were checked, so an ext-mode built on this
63
+ // documented extension point was the weakest-validated mode — an empty
64
+ // intent or an out-of-range ttlMs reached the wire unvalidated.
65
+ (0, validation_1.validateSessionStart)({
66
+ intent: input.intent,
67
+ participants: input.participants,
68
+ ttlMs: input.ttlMs,
69
+ maxSuspendMs: input.maxSuspendMs,
70
+ modeVersion: this.modeVersion,
71
+ configurationVersion: this.configurationVersion,
72
+ });
63
73
  const payload = (0, envelope_1.buildSessionStartPayload)({
64
74
  intent: input.intent,
65
75
  participants: input.participants,