@salesforce/sfdx-agent-harness-mastra 0.62.0 → 0.64.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.64.0] - 2026-09-02
7
+
8
+ ### Features
9
+ - **harness-mastra**: synthesize a terminal error when a Mastra stream closes without a finish @W-24026817@ ([#784](https://github.com/forcedotcom/agentic-dx/pull/784))
10
+
11
+ ## [0.63.0] - 2026-09-02
12
+
13
+ ### Features
14
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: reject concurrent turns with a typed SESSION_BUSY error @W-24044572@ ([#783](https://github.com/forcedotcom/agentic-dx/pull/783))
15
+
6
16
  ## [0.62.0] - 2026-09-02
7
17
 
8
18
  ### Fixes
@@ -261,6 +261,18 @@ export declare class MastraApprovalCoordinator {
261
261
  private timedOut;
262
262
  constructor(opts: MastraApprovalCoordinatorOptions);
263
263
  initialSink(): GenSink<ChatEvent>;
264
+ /**
265
+ * True once this turn has reached a terminal event — a natural `finish`
266
+ * pushed by the pump, or the synthetic `error` + `finish('error')` pair a
267
+ * `teardown` (abort / dispose / timeout / pump-error) emits. Both end the
268
+ * single per-turn sink, so `initial.isEnded()` is the authoritative "this
269
+ * turn is over" signal. A coordinator still pumping — including one PARKED on
270
+ * a `tool-approval-request` or a consumer-tool `submitToolResult` — has not
271
+ * ended its sink and is therefore NOT settled. `MastraHarness.stream()` reads
272
+ * this to reject a concurrent turn with `SESSION_BUSY` (a still-active
273
+ * coordinator) versus safely replace a settled one.
274
+ */
275
+ isSettled(): boolean;
264
276
  /**
265
277
  * Mastra's current run id. The harness's MCP-progress dispatcher uses
266
278
  * this to filter incoming `notifications/progress` to the coordinator
@@ -395,6 +407,40 @@ export declare class MastraApprovalCoordinator {
395
407
  */
396
408
  private driveResumeReturning;
397
409
  private awaitContinuation;
410
+ /**
411
+ * Terminal-event guard (spike agent-offline-failure-modes #07, W-24026817).
412
+ *
413
+ * Called at the pump's silent-close sites — where Mastra's `fullStream`
414
+ * exhausted with neither a `finish`/`error` chunk nor a recognized
415
+ * suspension, so the turn produced no terminal event of its own. A healthy
416
+ * turn never lands here: a normal turn ends on a `finish` chunk, and an
417
+ * approval turn's FINAL resume produces the real continuation `finish` (see
418
+ * ARCHITECTURE.md → "Pump loop", step 5). The realistic trigger is a
419
+ * COORDINATOR-level terminating chunk the adapter intentionally skips —
420
+ * today `tool-call-suspended` (a tool using Mastra's native `suspend()`,
421
+ * which the event adapter maps to `undefined`) — and the guard also closes
422
+ * the class for any future Mastra chunk that ends the stream without a
423
+ * paired finish.
424
+ *
425
+ * Without this, the pump would `initial.end()` the sink silently and the
426
+ * SDK's `DefaultChatSession.wrapEventStream` — which synthesizes its own
427
+ * terminal `finish` ONLY when an error landed — would record the turn as a
428
+ * false-success `chat-stream-completed` with NO terminal `FinishEvent`,
429
+ * breaking the SDK invariant "every chat turn ends with exactly one terminal
430
+ * FinishEvent". Routing through the single `teardown` path pushes the typed
431
+ * `error('incomplete-stream')` + `finish('error')` pair the SDK invariant
432
+ * requires, so the turn fails loudly and recoverably instead. This is the
433
+ * harness-side complement to the SDK-side invariant item #10 pinned.
434
+ *
435
+ * `teardown` short-circuits its sink pushes once the sink is ended
436
+ * (`GenSink.push` no-ops after `end()`), and every terminal path either
437
+ * ended the sink already or set `aborted`/`disposed`/`timedOut` (which the
438
+ * callers check first), so this can never double-signal a turn that already
439
+ * reached a terminal — including 8a's pass-through `empty-stream` error,
440
+ * which arrives as an `error` chunk that ends the sink before the stream
441
+ * closes.
442
+ */
443
+ private guardTerminalOnSilentClose;
398
444
  /**
399
445
  * Push an event onto the sink with MCP-server enrichment applied (when
400
446
  * the toolName resolves to a known MCP catalog entry). Mirrors the
@@ -184,6 +184,20 @@ export class MastraApprovalCoordinator {
184
184
  initialSink() {
185
185
  return this.initial;
186
186
  }
187
+ /**
188
+ * True once this turn has reached a terminal event — a natural `finish`
189
+ * pushed by the pump, or the synthetic `error` + `finish('error')` pair a
190
+ * `teardown` (abort / dispose / timeout / pump-error) emits. Both end the
191
+ * single per-turn sink, so `initial.isEnded()` is the authoritative "this
192
+ * turn is over" signal. A coordinator still pumping — including one PARKED on
193
+ * a `tool-approval-request` or a consumer-tool `submitToolResult` — has not
194
+ * ended its sink and is therefore NOT settled. `MastraHarness.stream()` reads
195
+ * this to reject a concurrent turn with `SESSION_BUSY` (a still-active
196
+ * coordinator) versus safely replace a settled one.
197
+ */
198
+ isSettled() {
199
+ return this.initial.isEnded();
200
+ }
187
201
  /**
188
202
  * Mastra's current run id. The harness's MCP-progress dispatcher uses
189
203
  * this to filter incoming `notifications/progress` to the coordinator
@@ -484,8 +498,8 @@ export class MastraApprovalCoordinator {
484
498
  // returns a stream that closes silently while still
485
499
  // suspended — the pump re-enters here to drive the next id.
486
500
  if (this.pendingSuspendIds.size === 0) {
487
- if (!this.initial.isEnded())
488
- this.initial.end();
501
+ // Terminal-event guard — see guardTerminalOnSilentClose.
502
+ this.guardTerminalOnSilentClose();
489
503
  return;
490
504
  }
491
505
  let nextId = pickIdWithDecision(this.pendingSuspendIds, this.decisions);
@@ -658,9 +672,9 @@ export class MastraApprovalCoordinator {
658
672
  if (sawSuspension || this.pendingSuspendIds.size > 0) {
659
673
  return 'suspended';
660
674
  }
661
- if (!sawTerminal && !this.initial.isEnded()) {
662
- this.pendingMcpToolCallStarts.clear();
663
- this.initial.end();
675
+ if (!sawTerminal) {
676
+ // Terminal-event guard — see guardTerminalOnSilentClose.
677
+ this.guardTerminalOnSilentClose();
664
678
  }
665
679
  return 'terminal';
666
680
  }
@@ -712,6 +726,44 @@ export class MastraApprovalCoordinator {
712
726
  this.continuationResolver = resolve;
713
727
  });
714
728
  }
729
+ /**
730
+ * Terminal-event guard (spike agent-offline-failure-modes #07, W-24026817).
731
+ *
732
+ * Called at the pump's silent-close sites — where Mastra's `fullStream`
733
+ * exhausted with neither a `finish`/`error` chunk nor a recognized
734
+ * suspension, so the turn produced no terminal event of its own. A healthy
735
+ * turn never lands here: a normal turn ends on a `finish` chunk, and an
736
+ * approval turn's FINAL resume produces the real continuation `finish` (see
737
+ * ARCHITECTURE.md → "Pump loop", step 5). The realistic trigger is a
738
+ * COORDINATOR-level terminating chunk the adapter intentionally skips —
739
+ * today `tool-call-suspended` (a tool using Mastra's native `suspend()`,
740
+ * which the event adapter maps to `undefined`) — and the guard also closes
741
+ * the class for any future Mastra chunk that ends the stream without a
742
+ * paired finish.
743
+ *
744
+ * Without this, the pump would `initial.end()` the sink silently and the
745
+ * SDK's `DefaultChatSession.wrapEventStream` — which synthesizes its own
746
+ * terminal `finish` ONLY when an error landed — would record the turn as a
747
+ * false-success `chat-stream-completed` with NO terminal `FinishEvent`,
748
+ * breaking the SDK invariant "every chat turn ends with exactly one terminal
749
+ * FinishEvent". Routing through the single `teardown` path pushes the typed
750
+ * `error('incomplete-stream')` + `finish('error')` pair the SDK invariant
751
+ * requires, so the turn fails loudly and recoverably instead. This is the
752
+ * harness-side complement to the SDK-side invariant item #10 pinned.
753
+ *
754
+ * `teardown` short-circuits its sink pushes once the sink is ended
755
+ * (`GenSink.push` no-ops after `end()`), and every terminal path either
756
+ * ended the sink already or set `aborted`/`disposed`/`timedOut` (which the
757
+ * callers check first), so this can never double-signal a turn that already
758
+ * reached a terminal — including 8a's pass-through `empty-stream` error,
759
+ * which arrives as an `error` chunk that ends the sink before the stream
760
+ * closes.
761
+ */
762
+ guardTerminalOnSilentClose() {
763
+ if (this.initial.isEnded())
764
+ return;
765
+ this.teardown('incomplete-stream', new Error('The model stream closed without a terminal finish or error event.'), 'incomplete-stream');
766
+ }
715
767
  // ───────────────────────── routing helpers ──────────────────────────────
716
768
  /**
717
769
  * Push an event onto the sink with MCP-server enrichment applied (when
@@ -1618,12 +1618,20 @@ export class MastraHarness {
1618
1618
  // round-trip per tool call.
1619
1619
  const { lookupPolicy, batchApprovals } = this.buildPolicyGate(agentState, options);
1620
1620
  const gatingActive = lookupPolicy !== undefined;
1621
- // Dispose any coordinator the prior turn on this thread allocated.
1622
- // Without this a stale coordinator from the prior turn would still
1623
- // route settle calls and accumulate listeners. Mode-flip safe: the
1624
- // new coordinator is constructed fresh below regardless of the new
1625
- // turn's mode.
1621
+ // Concurrency guard (spike agent-offline-failure-modes #10, W-24044572):
1622
+ // reject a second turn on a thread whose prior turn is still ACTIVE with a
1623
+ // typed `SESSION_BUSY` error, instead of silently disposing the first
1624
+ // (which surfaced to the first consumer as a spurious `DISPOSED` — a
1625
+ // "silent lost turn"). A coordinator is active until its sink ends (natural
1626
+ // finish or teardown), so one still pumping — including one parked on an
1627
+ // approval / consumer-tool result — blocks a new turn. An already-settled
1628
+ // coordinator (finished or errored) is safe to dispose-and-replace, so the
1629
+ // idempotent settling of an already-terminal turn is preserved.
1626
1630
  const priorCoordinator = agentState.coordinators.get(threadId);
1631
+ if (priorCoordinator && !priorCoordinator.isSettled()) {
1632
+ throw new AgentSDKError(`A turn is already in progress for thread "${threadId}" on agent "${agentId}". ` +
1633
+ 'Only one turn may run at a time per thread; await the in-flight turn before starting another.', AgentSDKErrorType.SESSION_BUSY);
1634
+ }
1627
1635
  if (priorCoordinator) {
1628
1636
  priorCoordinator.dispose();
1629
1637
  agentState.coordinators.delete(threadId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,12 +42,12 @@
42
42
  "openai": "^7.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.63.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.65.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
49
  "@salesforce/harness-conformance": "0.0.1",
50
- "@salesforce/sfdx-agent-sdk": "0.63.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.65.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.27",