@intx/harness 0.3.0 → 0.4.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/dist/harness.d.ts CHANGED
@@ -64,45 +64,6 @@ export interface Harness {
64
64
  * implementation to use.
65
65
  */
66
66
  export type MailToolWrapper = (transport: MessageTransport) => Omit<ToolBundle, "dispose">;
67
- /**
68
- * Invoke the caller-supplied `onReplySendFailed` callback and absorb any
69
- * failure it raises. Extracted from the reply drain so the await-the-
70
- * callback contract is testable in isolation: a bare invocation would
71
- * compile (TypeScript admits `async () => void` as satisfying a `void`-
72
- * returning signature) but would let an async callback's rejection
73
- * escape as an unhandled promise rejection. Awaiting protects against
74
- * that; the helper exists so the protection is asserted by a test
75
- * rather than implied by inspection of the drain.
76
- *
77
- * Exported only for the regression test in this package; no external
78
- * consumer should call it.
79
- *
80
- * The export-and-mark-internal shape is the codebase's convention
81
- * for helpers that exist to make a production-code contract
82
- * testable in isolation. A separate `@intx/harness/testing`
83
- * entry-point was considered and rejected: the helper is one
84
- * try/catch wrapper around the production callback, tightly
85
- * coupled to the `MailEnv` callback type defined adjacent to it.
86
- * Moving it would either duplicate the production code in a test
87
- * module (defeating the point) or require a parallel entry-point
88
- * whose only export is a single function -- bundler ceremony for a
89
- * boundary TypeScript cannot enforce anyway, since deep imports
90
- * (`@intx/harness/src/harness`) reach the same module regardless
91
- * of what `index.ts` re-exports. The docstring convention is
92
- * load-bearing here: the marker is the contract.
93
- *
94
- * `invokeReplyDrainTerminated` (below) follows the same shape for
95
- * the same reason.
96
- */
97
- export declare function invokeReplySendFailed(callback: NonNullable<MailEnv["onReplySendFailed"]>, cause: unknown): Promise<void>;
98
- /**
99
- * Invoke the caller-supplied `onReplyDrainTerminated` callback and
100
- * absorb any failure it raises. Mirrors `invokeReplySendFailed`:
101
- * extracted from the reply drain so the await-the-callback contract
102
- * is testable in isolation, exported only for the regression test in
103
- * this package.
104
- */
105
- export declare function invokeReplyDrainTerminated(callback: NonNullable<MailEnv["onReplyDrainTerminated"]>, cause: unknown): Promise<void>;
106
67
  /**
107
68
  * Build the `load` / `writeMetadata` overrides the harness layers onto
108
69
  * `env.storage`. Extracted from `createHarness` so the dirty-bit gating
@@ -118,12 +79,11 @@ export declare function invokeReplyDrainTerminated(callback: NonNullable<MailEnv
118
79
  * pre-commit disk value.
119
80
  *
120
81
  * Exported for the regression test in this package; no external
121
- * consumer should call it. Same shape and rationale as
122
- * `invokeReplySendFailed` and `invokeReplyDrainTerminated` above --
123
- * the helper is tightly coupled to the dirty-bit gating semantics
124
- * that live in this module, and a separate testing entry-point
125
- * would buy bundler ceremony for a boundary TypeScript cannot
126
- * enforce. The docstring "internal" marker is the contract.
82
+ * consumer should call it. The helper is tightly coupled to the
83
+ * dirty-bit gating semantics that live in this module, and a separate
84
+ * testing entry-point would buy bundler ceremony for a boundary
85
+ * TypeScript cannot enforce. The docstring "internal" marker is the
86
+ * contract.
127
87
  */
128
88
  export declare function createWrappedStorageOverrides(baseStorage: ContextStore, connectorRouter: ReturnType<typeof createConnectorRouter>, isInMemoryStateAuthoritative: () => boolean): Pick<ContextStore, "load" | "writeMetadata">;
129
89
  /**
package/dist/harness.js CHANGED
@@ -14,60 +14,8 @@
14
14
  import { createAgent, defineTool, } from "@intx/agent";
15
15
  import { getLogger } from "@intx/log";
16
16
  import { createConnectorRouter } from "./connector-router.js";
17
+ import { driveConnectorReplies } from "./reply-drain.js";
17
18
  const logger = getLogger(["interchange", "harness"]);
18
- /**
19
- * Invoke the caller-supplied `onReplySendFailed` callback and absorb any
20
- * failure it raises. Extracted from the reply drain so the await-the-
21
- * callback contract is testable in isolation: a bare invocation would
22
- * compile (TypeScript admits `async () => void` as satisfying a `void`-
23
- * returning signature) but would let an async callback's rejection
24
- * escape as an unhandled promise rejection. Awaiting protects against
25
- * that; the helper exists so the protection is asserted by a test
26
- * rather than implied by inspection of the drain.
27
- *
28
- * Exported only for the regression test in this package; no external
29
- * consumer should call it.
30
- *
31
- * The export-and-mark-internal shape is the codebase's convention
32
- * for helpers that exist to make a production-code contract
33
- * testable in isolation. A separate `@intx/harness/testing`
34
- * entry-point was considered and rejected: the helper is one
35
- * try/catch wrapper around the production callback, tightly
36
- * coupled to the `MailEnv` callback type defined adjacent to it.
37
- * Moving it would either duplicate the production code in a test
38
- * module (defeating the point) or require a parallel entry-point
39
- * whose only export is a single function -- bundler ceremony for a
40
- * boundary TypeScript cannot enforce anyway, since deep imports
41
- * (`@intx/harness/src/harness`) reach the same module regardless
42
- * of what `index.ts` re-exports. The docstring convention is
43
- * load-bearing here: the marker is the contract.
44
- *
45
- * `invokeReplyDrainTerminated` (below) follows the same shape for
46
- * the same reason.
47
- */
48
- export async function invokeReplySendFailed(callback, cause) {
49
- try {
50
- await callback(cause);
51
- }
52
- catch (callbackError) {
53
- logger.error `onReplySendFailed callback threw: ${callbackError}`;
54
- }
55
- }
56
- /**
57
- * Invoke the caller-supplied `onReplyDrainTerminated` callback and
58
- * absorb any failure it raises. Mirrors `invokeReplySendFailed`:
59
- * extracted from the reply drain so the await-the-callback contract
60
- * is testable in isolation, exported only for the regression test in
61
- * this package.
62
- */
63
- export async function invokeReplyDrainTerminated(callback, cause) {
64
- try {
65
- await callback(cause);
66
- }
67
- catch (callbackError) {
68
- logger.error `onReplyDrainTerminated callback threw: ${callbackError}`;
69
- }
70
- }
71
19
  /**
72
20
  * Build the `load` / `writeMetadata` overrides the harness layers onto
73
21
  * `env.storage`. Extracted from `createHarness` so the dirty-bit gating
@@ -83,12 +31,11 @@ export async function invokeReplyDrainTerminated(callback, cause) {
83
31
  * pre-commit disk value.
84
32
  *
85
33
  * Exported for the regression test in this package; no external
86
- * consumer should call it. Same shape and rationale as
87
- * `invokeReplySendFailed` and `invokeReplyDrainTerminated` above --
88
- * the helper is tightly coupled to the dirty-bit gating semantics
89
- * that live in this module, and a separate testing entry-point
90
- * would buy bundler ceremony for a boundary TypeScript cannot
91
- * enforce. The docstring "internal" marker is the contract.
34
+ * consumer should call it. The helper is tightly coupled to the
35
+ * dirty-bit gating semantics that live in this module, and a separate
36
+ * testing entry-point would buy bundler ceremony for a boundary
37
+ * TypeScript cannot enforce. The docstring "internal" marker is the
38
+ * contract.
92
39
  */
93
40
  export function createWrappedStorageOverrides(baseStorage, connectorRouter, isInMemoryStateAuthoritative) {
94
41
  return {
@@ -237,8 +184,8 @@ export async function createHarness(def, env) {
237
184
  const agentEnv = { ...env, storage: wrappedStorage };
238
185
  const agent = await createAgent(def, agentEnv);
239
186
  // From here through the final `return`, the agent is constructed
240
- // and the workdir lock is held. Anything that throws -- the reply
241
- // drain's IIFE-construction expression, `transport.watch()`,
187
+ // and the workdir lock is held. Anything that throws -- the
188
+ // `driveConnectorReplies` setup, `transport.watch()`,
242
189
  // anything in the watch callback's synchronous registration -- has
243
190
  // to release the lock by closing the agent before re-raising; the
244
191
  // caller never sees the agent and cannot do it themselves.
@@ -252,63 +199,24 @@ export async function createHarness(def, env) {
252
199
  // else flows past unobserved. Other consumers can subscribe to the
253
200
  // exposed `stream()` method to see the same events.
254
201
  //
255
- // Reply sends are serialized through `replyChain` so two replies
256
- // fired in quick succession do not interleave their
257
- // composeReply / transport.send / onReplySent sequence -- the
258
- // second reply waits for the first's receipt to land in the router
259
- // before composing its own.
260
- let stopReplyDrain = false;
261
- let replyChain = Promise.resolve();
262
- const replyDrainDone = (async () => {
263
- try {
264
- for await (const event of agent.stream()) {
265
- if (stopReplyDrain)
266
- break;
267
- if (event.type === "connector.reply") {
268
- const replyContent = event.data.content;
269
- replyChain = replyChain.then(async () => {
270
- try {
271
- const parts = connectorRouter.composeReply();
272
- const receipt = await transport.send({
273
- ...parts,
274
- content: replyContent,
275
- type: "conversation.message",
276
- });
277
- connectorRouter.onReplySent(receipt);
278
- }
279
- catch (cause) {
280
- // The reply is dropped and the router state stays at
281
- // its pre-send value. Surface the loss to the caller's
282
- // optional onReplySendFailed callback in addition to
283
- // the operator-facing log so programmatic consumers
284
- // (retries, alerting) can observe what logger.error
285
- // alone hides.
286
- logger.error `Failed to send connector reply: ${cause}`;
287
- if (env.onReplySendFailed !== undefined) {
288
- await invokeReplySendFailed(env.onReplySendFailed, cause);
289
- }
290
- }
291
- });
292
- }
293
- }
294
- // Drain any pending reply before the loop exits so close() sees
295
- // a settled state.
296
- await replyChain;
297
- }
298
- catch (cause) {
299
- // The agent's stream throws on backpressure violations; log and
300
- // exit the drain. The reply path stops working but the rest of
301
- // the harness keeps running until close() tears it down.
302
- // Surface the loss to the caller's optional
303
- // `onReplyDrainTerminated` callback so programmatic consumers
304
- // (alerting, watchdogs) can observe what `logger.warn` alone
305
- // hides.
306
- logger.warn `Reply-drain stream terminated: ${cause}`;
307
- if (env.onReplyDrainTerminated !== undefined) {
308
- await invokeReplyDrainTerminated(env.onReplyDrainTerminated, cause);
309
- }
310
- }
311
- })();
202
+ // The shared `driveConnectorReplies` helper owns the loop: reply
203
+ // serialization (a second reply waits for the first's receipt to
204
+ // advance the router before composing its own), per-reply failure
205
+ // surfacing to `onReplySendFailed`, and abnormal-termination
206
+ // surfacing to `onReplyDrainTerminated`. The warm workflow-host
207
+ // path drives replies through the same helper.
208
+ const replyDrain = driveConnectorReplies({
209
+ stream: agent.stream(),
210
+ composeReply: () => connectorRouter.composeReply(),
211
+ send: (message) => transport.send(message),
212
+ onReplySent: (receipt) => connectorRouter.onReplySent(receipt),
213
+ ...(env.onReplySendFailed !== undefined
214
+ ? { onSendFailed: env.onReplySendFailed }
215
+ : {}),
216
+ ...(env.onReplyDrainTerminated !== undefined
217
+ ? { onTerminated: env.onReplyDrainTerminated }
218
+ : {}),
219
+ });
312
220
  // Delete a message from the INBOX after it has been delivered to the
313
221
  // reactor.
314
222
  //
@@ -412,12 +320,12 @@ export async function createHarness(def, env) {
412
320
  return;
413
321
  stopped = true;
414
322
  unsubscribe();
415
- stopReplyDrain = true;
323
+ replyDrain.stop();
416
324
  await agent.close();
417
325
  // The reply-drain loop exits once the underlying stream closes
418
326
  // (close() above terminates streamConsumers). Awaiting here makes
419
327
  // close idempotent and lets callers rely on a settled state.
420
- await replyDrainDone;
328
+ await replyDrain.done;
421
329
  }
422
330
  const harness = {
423
331
  close,
package/dist/index.d.ts CHANGED
@@ -7,3 +7,5 @@ export { createCredentialCapability, reconcileDeclaredCredentials, } from "./cre
7
7
  export type { CredentialCapabilityDeps, HostCredentialCapability, ResolvedCredentialBinding, } from "./credential-capability.js";
8
8
  export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
9
9
  export type { ConnectorRouter, ConnectorReplyParts, ConnectorRouterOptions, RouteDecision, } from "./connector-router.js";
10
+ export { driveConnectorReplies } from "./reply-drain.js";
11
+ export type { AgentEventStream, ConnectorReplyDrain, ConnectorReplyDrainOpts, ReplySettlement, } from "./reply-drain.js";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
3
3
  export { createCredentialProviderRegistry, createHttpCredentialProvider, builtinCredentialProviders, } from "./credential-providers.js";
4
4
  export { createCredentialCapability, reconcileDeclaredCredentials, } from "./credential-capability.js";
5
5
  export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
6
+ export { driveConnectorReplies } from "./reply-drain.js";
@@ -0,0 +1,123 @@
1
+ import type { Agent } from "@intx/agent";
2
+ import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
3
+ import type { ConnectorReplyParts } from "./connector-router.js";
4
+ /**
5
+ * The agent event stream the drain consumes -- exactly `agent.stream()`'s
6
+ * type. The stream yields the reactor's full emitted-event union (wider than
7
+ * `InferenceEvent`: it also carries `message.received`), so the drain accepts
8
+ * that union and lets every non-`connector.reply` event flow past untouched.
9
+ */
10
+ export type AgentEventStream = ReturnType<Agent["stream"]>;
11
+ export interface ConnectorReplyDrainOpts {
12
+ /** The agent event stream to drain. Each `connector.reply` sends a reply. */
13
+ stream: AgentEventStream;
14
+ /**
15
+ * Produce the threading headers (`to`, `cc`, `inReplyTo`, `subject`) for
16
+ * the active connector thread. Throws when no thread is active; the throw
17
+ * is caught per reply and routed to `onSendFailed`.
18
+ */
19
+ composeReply: () => ConnectorReplyParts;
20
+ /**
21
+ * Send the composed reply. The drain builds the `OutboundMessage` from
22
+ * `composeReply()`'s parts plus the reply content and a
23
+ * `conversation.message` type; the caller's `send` routes it to the
24
+ * transport / outbound bridge.
25
+ */
26
+ send: (message: OutboundMessage) => Promise<SendReceipt>;
27
+ /**
28
+ * Resolve the full RFC 5322 References chain for a reply whose parent is
29
+ * `inReplyTo` (the Message-Id of the message being answered). Returns the
30
+ * parent's own References plus the parent's Message-Id, in order, so the
31
+ * outbound reply carries the complete conversational ancestry rather than
32
+ * a truncated single element. Returns `undefined` when the parent cannot be
33
+ * located (the very first reply on a fresh thread, or a malformed id); the
34
+ * drain then omits `references` and the transport derives `[inReplyTo]`.
35
+ *
36
+ * Optional: a caller with no mailbox to consult (`createHarness`) omits it,
37
+ * leaving the pre-existing single-element threading unchanged. The warm
38
+ * workflow-host wiring supplies it from the deployment's committed mailbox.
39
+ */
40
+ resolveReferences?: (inReplyTo: string) => Promise<string[] | undefined>;
41
+ /**
42
+ * Advance connector state after a successful send. May be synchronous
43
+ * (the in-process router's `onReplySent`) or asynchronous (a durable
44
+ * store that persists the advanced `lastMessageId`); the drain awaits it
45
+ * before composing the next reply.
46
+ */
47
+ onReplySent: (receipt: SendReceipt) => void | Promise<void>;
48
+ /**
49
+ * Invoked when `composeReply`, `send`, or `onReplySent` throws for one
50
+ * reply. The reply is dropped and the connector thread stays at its
51
+ * pre-send value. The drain awaits the callback (so an async callback's
52
+ * rejection is observed and logged, not left as an unhandled rejection)
53
+ * and absorbs any error it raises.
54
+ */
55
+ onSendFailed?: (cause: unknown) => void | Promise<void>;
56
+ /**
57
+ * Invoked when the stream's `for await` loop exits abnormally -- the
58
+ * documented case is a backpressure error thrown by the agent event
59
+ * stream. After it fires the drain no longer forwards replies. Awaited
60
+ * and absorbed the same way as `onSendFailed`.
61
+ */
62
+ onTerminated?: (cause: unknown) => void | Promise<void>;
63
+ }
64
+ /**
65
+ * The settled outcome of one reply the drain processed. `ok` distinguishes a
66
+ * durably-sent reply (the send acked and `onReplySent` advanced the thread)
67
+ * from a failed one (compose, send, or `onReplySent` threw). A caller gating a
68
+ * side effect on the reply reaching the transport awaits the barrier and acts
69
+ * only on `ok: true`; `ok: false` carries the failure `cause` so the caller can
70
+ * surface it rather than treat the reply as sent.
71
+ */
72
+ export type ReplySettlement = {
73
+ readonly ok: true;
74
+ readonly receipt: SendReceipt;
75
+ } | {
76
+ readonly ok: false;
77
+ readonly cause: unknown;
78
+ };
79
+ export interface ConnectorReplyDrain {
80
+ /**
81
+ * Settles once the drain loop has exited and its last pending reply has
82
+ * drained. Always resolves -- per-reply and terminal failures are routed
83
+ * to the callbacks, never thrown out of here -- so a caller can await it
84
+ * on teardown without guarding a rejection.
85
+ */
86
+ readonly done: Promise<void>;
87
+ /**
88
+ * Signal the loop to stop at the next event. The loop also exits on its
89
+ * own when the underlying stream ends (e.g. the agent closes); `stop()`
90
+ * is the cooperative early exit for a caller tearing down before then.
91
+ */
92
+ stop(): void;
93
+ /**
94
+ * The count of replies that have SETTLED so far -- sent-and-acked or failed.
95
+ * Monotonic. A per-turn caller captures this BEFORE the `agent.send` that may
96
+ * produce a reply, then, for a turn that did produce a `connector.reply`,
97
+ * awaits `waitForReplyAfter(captured)` to block until THIS turn's reply
98
+ * settles. The capture-before-send ordering is required: the agent resolves
99
+ * `agent.send` in the same synchronous step that pushes the `connector.reply`
100
+ * onto this drain's stream, so the reply is not yet enqueued when `send`
101
+ * resolves -- a post-send snapshot would miss it.
102
+ */
103
+ replySeq(): number;
104
+ /**
105
+ * Resolve once more than `n` replies have settled -- i.e. the reply at index
106
+ * `n` (the `(n + 1)`th reply the drain processed) has settled -- with that
107
+ * reply's settlement. Because the warm agent is strictly serial and the drain
108
+ * is FIFO, a turn that captured `n` from `replySeq()` before its send and
109
+ * produced exactly one reply awaits reply `n` here.
110
+ *
111
+ * When the drain loop exits (stream end, `stop()`, or an abnormal
112
+ * termination) before reply `n` settles, resolves with a failure settlement
113
+ * rather than hanging, so a caller awaiting a reply that will never arrive
114
+ * fails its turn instead of blocking forever.
115
+ */
116
+ waitForReplyAfter(n: number): Promise<ReplySettlement>;
117
+ }
118
+ /**
119
+ * Drive an agent's `connector.reply` events out through a transport. Returns
120
+ * immediately with a handle; the drain runs in the background until the
121
+ * stream ends or `stop()` is called.
122
+ */
123
+ export declare function driveConnectorReplies(opts: ConnectorReplyDrainOpts): ConnectorReplyDrain;
@@ -0,0 +1,179 @@
1
+ // Shared connector reply drain for the agent harness.
2
+ //
3
+ // A director emits a `connector.reply` event when the agent produces an
4
+ // outbound reply on its connector thread. Draining that event means:
5
+ // compose the threading headers for the active thread, send the reply
6
+ // through the transport, then advance the thread's `lastMessageId` from
7
+ // the send receipt. This module owns that loop so both the harness
8
+ // composition layer (`createHarness`) and the warm workflow-host agent
9
+ // path drive replies through one implementation rather than each keeping
10
+ // its own copy.
11
+ //
12
+ // The loop subscribes an agent event stream and serializes every reply
13
+ // through a single chain: two replies fired in quick succession do not
14
+ // interleave their compose / send / onReplySent sequence -- the second
15
+ // waits for the first's receipt to advance the thread before composing
16
+ // against it. A per-reply failure (compose, send, or onReplySent) is
17
+ // surfaced to `onSendFailed` and the reply is dropped with the thread left
18
+ // at its pre-send state; an abnormal stream termination (e.g. an agent
19
+ // stream backpressure violation) is surfaced to `onTerminated`. Neither
20
+ // escapes the returned `done` promise -- it always resolves -- so a caller
21
+ // can await teardown without guarding a rejection.
22
+ import { getLogger } from "@intx/log";
23
+ const logger = getLogger(["interchange", "harness", "reply-drain"]);
24
+ async function invokeAbsorbing(callback, cause, label) {
25
+ try {
26
+ await callback(cause);
27
+ }
28
+ catch (callbackError) {
29
+ logger.error `${label} callback threw: ${callbackError}`;
30
+ }
31
+ }
32
+ /**
33
+ * Drive an agent's `connector.reply` events out through a transport. Returns
34
+ * immediately with a handle; the drain runs in the background until the
35
+ * stream ends or `stop()` is called.
36
+ */
37
+ export function driveConnectorReplies(opts) {
38
+ let stopped = false;
39
+ // Reply sends are serialized through `replyChain` so two replies fired in
40
+ // quick succession do not interleave their compose / send / onReplySent
41
+ // sequence -- the second waits for the first's receipt to advance the
42
+ // thread before composing its own.
43
+ let replyChain = Promise.resolve();
44
+ // Per-turn settle barrier. `settlements[i]` is the outcome of the `i`th reply
45
+ // the drain processed; `settlements.length` is the monotonic settled count a
46
+ // caller snapshots through `replySeq()`. Waiters block until the settled
47
+ // count passes their target index, then resolve with that reply's outcome. A
48
+ // reply is recorded here on BOTH success and failure so a waiter never hangs;
49
+ // the outcome's `ok` tells the caller which happened.
50
+ const settlements = [];
51
+ let terminated = false;
52
+ let waiters = [];
53
+ const terminalSettlement = () => ({
54
+ ok: false,
55
+ cause: new Error("connector reply drain terminated before the reply was sent"),
56
+ });
57
+ function settlementAt(index) {
58
+ const settlement = settlements[index];
59
+ if (settlement === undefined) {
60
+ // Reached only if a waiter resolves for an index the drain never
61
+ // recorded -- an internal invariant break, surfaced loudly rather than
62
+ // handed back as a silent fallback.
63
+ throw new Error(`connector reply drain: settlement ${String(index)} missing though ` +
64
+ `${String(settlements.length)} replies have settled`);
65
+ }
66
+ return settlement;
67
+ }
68
+ function recordSettlement(settlement) {
69
+ settlements.push(settlement);
70
+ const settledCount = settlements.length;
71
+ const stillWaiting = [];
72
+ for (const waiter of waiters) {
73
+ if (settledCount > waiter.target) {
74
+ waiter.resolve(settlementAt(waiter.target));
75
+ }
76
+ else {
77
+ stillWaiting.push(waiter);
78
+ }
79
+ }
80
+ waiters = stillWaiting;
81
+ }
82
+ function releaseWaitersOnTermination() {
83
+ terminated = true;
84
+ const outstanding = waiters;
85
+ waiters = [];
86
+ for (const waiter of outstanding) {
87
+ // A waiter whose reply settled before teardown gets its real outcome; one
88
+ // whose reply never arrived (the drain stopped first) gets a terminal
89
+ // failure so the caller fails its turn rather than blocking.
90
+ waiter.resolve(settlements.length > waiter.target
91
+ ? settlementAt(waiter.target)
92
+ : terminalSettlement());
93
+ }
94
+ }
95
+ const done = (async () => {
96
+ try {
97
+ for await (const event of opts.stream) {
98
+ if (stopped)
99
+ break;
100
+ if (event.type !== "connector.reply")
101
+ continue;
102
+ const content = event.data.content;
103
+ replyChain = replyChain.then(async () => {
104
+ try {
105
+ const parts = opts.composeReply();
106
+ // Resolve the full References ancestry for the parent this reply
107
+ // answers, when the caller supplies a resolver. A resolver miss
108
+ // (parent absent, malformed id) yields `undefined`, and the
109
+ // transport derives `[inReplyTo]` as before.
110
+ const references = opts.resolveReferences !== undefined
111
+ ? await opts.resolveReferences(parts.inReplyTo)
112
+ : undefined;
113
+ const receipt = await opts.send({
114
+ ...parts,
115
+ content,
116
+ type: "conversation.message",
117
+ ...(references !== undefined && references.length > 0
118
+ ? { references }
119
+ : {}),
120
+ });
121
+ await opts.onReplySent(receipt);
122
+ recordSettlement({ ok: true, receipt });
123
+ }
124
+ catch (cause) {
125
+ // The reply is dropped and the connector thread stays at its
126
+ // pre-send value. Surface the loss to `onSendFailed` in addition
127
+ // to the operator-facing log so programmatic consumers (retries,
128
+ // alerting) can observe what the log alone hides. Record the
129
+ // failure on the barrier too, so a per-turn caller awaiting this
130
+ // reply sees `ok: false` rather than treating it as sent.
131
+ logger.error `Failed to send connector reply: ${cause}`;
132
+ if (opts.onSendFailed !== undefined) {
133
+ await invokeAbsorbing(opts.onSendFailed, cause, "onSendFailed");
134
+ }
135
+ recordSettlement({ ok: false, cause });
136
+ }
137
+ });
138
+ }
139
+ }
140
+ catch (cause) {
141
+ // The agent's stream throws on backpressure violations; log and exit.
142
+ // The reply path stops working but the caller's other consumers keep
143
+ // running until teardown. Surface the loss to `onTerminated` so
144
+ // programmatic consumers (alerting, watchdogs) can observe it.
145
+ logger.warn `Reply-drain stream terminated: ${cause}`;
146
+ if (opts.onTerminated !== undefined) {
147
+ await invokeAbsorbing(opts.onTerminated, cause, "onTerminated");
148
+ }
149
+ }
150
+ finally {
151
+ // Drain the pending reply before the loop exits so its settlement is
152
+ // recorded and a caller awaiting `done` sees a settled state. Then
153
+ // release any barrier waiter still blocked on a reply that will never
154
+ // arrive, so a per-turn caller cannot hang past teardown.
155
+ await replyChain;
156
+ releaseWaitersOnTermination();
157
+ }
158
+ })();
159
+ return {
160
+ done,
161
+ stop() {
162
+ stopped = true;
163
+ },
164
+ replySeq() {
165
+ return settlements.length;
166
+ },
167
+ waitForReplyAfter(n) {
168
+ if (settlements.length > n) {
169
+ return Promise.resolve(settlementAt(n));
170
+ }
171
+ if (terminated) {
172
+ return Promise.resolve(terminalSettlement());
173
+ }
174
+ return new Promise((resolve) => {
175
+ waiters.push({ target: n, resolve });
176
+ });
177
+ },
178
+ };
179
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@intx/harness",
3
3
  "description": "Mail-transport composition layer over @intx/agent adding INBOX watch and connector routing",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "license": "LGPL-2.1-only",
6
6
  "type": "module",
7
7
  "exports": {
@@ -12,15 +12,15 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@intx/agent": "0.3.0",
16
- "@intx/authz": "0.3.0",
17
- "@intx/log": "0.3.0",
18
- "@intx/types": "0.3.0"
15
+ "@intx/agent": "0.4.0",
16
+ "@intx/authz": "0.4.0",
17
+ "@intx/log": "0.4.0",
18
+ "@intx/types": "0.4.0"
19
19
  },
20
20
  "devDependencies": {
21
- "@intx/inference-testing": "0.3.0",
22
- "@intx/mime": "0.3.0",
23
- "@intx/storage-isogit": "0.3.0",
21
+ "@intx/inference-testing": "0.4.0",
22
+ "@intx/mime": "0.4.0",
23
+ "@intx/storage-isogit": "0.4.0",
24
24
  "arktype": "^2.1.29"
25
25
  },
26
26
  "files": [