@band-ai/sdk 0.3.4 → 0.4.1
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/README.md +54 -1
- package/dist/{ClaudeSDKAdapter-CXud2DBE.d.ts → CopilotACPAdapter-B_AVyAl6.d.ts} +249 -19
- package/dist/{ClaudeSDKAdapter-Cx6zhSaG.d.cts → CopilotACPAdapter-Clid9xGR.d.cts} +249 -19
- package/dist/{opencode-2YdscIc1.d.cts → acp-client-D-I_5lK-.d.cts} +23 -4
- package/dist/{opencode-Bld62x5t.d.ts → acp-client-DUXyczlF.d.ts} +23 -4
- package/dist/acp-server-BlcYMxKt.d.ts +17 -0
- package/dist/acp-server-CJhclo6P.d.cts +17 -0
- package/dist/adapters.cjs +2257 -969
- package/dist/adapters.d.cts +21 -132
- package/dist/adapters.d.ts +21 -132
- package/dist/adapters.js +26 -1922
- package/dist/{backends-D-dBdDm-.d.ts → backends-CgsPQa9B.d.ts} +2 -2
- package/dist/{backends-ZLxmQrTZ.d.cts → backends-Dh2WXcHQ.d.cts} +2 -2
- package/dist/{chunk-JY75VSQK.js → chunk-4OVNFDD7.js} +14 -1
- package/dist/chunk-6BE2EG5P.js +128 -0
- package/dist/{chunk-U6AFCLYZ.js → chunk-6TGXV5TE.js} +1824 -514
- package/dist/{chunk-OH6VVVKT.js → chunk-CBCHTTUY.js} +10 -0
- package/dist/{chunk-K5KAEYPF.js → chunk-HNWZZXAE.js} +288 -120
- package/dist/chunk-I34SXMTJ.js +6 -0
- package/dist/{chunk-XWHWJP4I.js → chunk-JDW5WSGF.js} +2 -2
- package/dist/{chunk-KXJ74J73.js → chunk-JIZE4EC6.js} +4 -10
- package/dist/chunk-OGOXY43L.js +119 -0
- package/dist/{chunk-W5IAI6Z4.js → chunk-OVXVZUPO.js} +5 -5
- package/dist/{chunk-YJ42AZO4.js → chunk-RJNJUISR.js} +5 -2
- package/dist/{chunk-MQY74R7D.js → chunk-SHZBLLDQ.js} +21 -9
- package/dist/{chunk-JADCAMZY.js → chunk-TGDDFBYS.js} +45 -0
- package/dist/{chunk-US226AS4.js → chunk-UENIC5FJ.js} +14 -11
- package/dist/{chunk-MQBBWYRP.js → chunk-UFO6XMCV.js} +3404 -572
- package/dist/{chunk-WA7CHXUS.js → chunk-UL3Y5C4J.js} +2 -2
- package/dist/{chunk-WL2HNJZ6.js → chunk-VOMJRQOM.js} +3 -3
- package/dist/{chunk-OD2G5LFQ.js → chunk-ZYOXI4JL.js} +41 -2
- package/dist/config.cjs +2 -12
- package/dist/config.js +2 -2
- package/dist/converters.cjs +36 -11
- package/dist/converters.d.cts +7 -5
- package/dist/converters.d.ts +7 -5
- package/dist/converters.js +11 -13
- package/dist/core.cjs +239 -5
- package/dist/core.d.cts +71 -6
- package/dist/core.d.ts +71 -6
- package/dist/core.js +31 -6
- package/dist/{customTools-BzF0IISO.d.cts → customTools-Bfecd0mJ.d.cts} +1 -1
- package/dist/{customTools-BzF0IISO.d.ts → customTools-Bfecd0mJ.d.ts} +1 -1
- package/dist/{disconnectReason-IuADl6TH.d.ts → disconnectReason-Cctmg1SN.d.cts} +1 -1
- package/dist/{disconnectReason-IuADl6TH.d.cts → disconnectReason-Cctmg1SN.d.ts} +1 -1
- package/dist/{dtos-B8KU_q4d.d.ts → dtos-CjQsN1_h.d.cts} +5 -12
- package/dist/{dtos-B8KU_q4d.d.cts → dtos-CjQsN1_h.d.ts} +5 -12
- package/dist/index.cjs +5289 -1474
- package/dist/index.d.cts +61 -22
- package/dist/index.d.ts +61 -22
- package/dist/index.js +151 -43
- package/dist/linear.cjs +45 -7
- package/dist/linear.d.cts +4 -3
- package/dist/linear.d.ts +4 -3
- package/dist/linear.js +10 -10
- package/dist/mcp-claude.cjs +11 -6
- package/dist/mcp-claude.d.cts +4 -3
- package/dist/mcp-claude.d.ts +4 -3
- package/dist/mcp-claude.js +4 -5
- package/dist/mcp.cjs +13 -7
- package/dist/mcp.d.cts +6 -5
- package/dist/mcp.d.ts +6 -5
- package/dist/mcp.js +5 -6
- package/dist/{pagination-DqdkQArT.d.cts → pagination-BZDhB_nO.d.cts} +2 -2
- package/dist/{pagination-BgEx-HeL.d.ts → pagination-CPz0J_vQ.d.ts} +2 -2
- package/dist/{protocols-Bz1rxcUg.d.cts → protocols-CabgO52c.d.cts} +38 -3
- package/dist/{protocols-Bwwcql0h.d.ts → protocols-Dpci6uae.d.ts} +38 -3
- package/dist/rest.cjs +43 -1
- package/dist/rest.d.cts +6 -5
- package/dist/rest.d.ts +6 -5
- package/dist/rest.js +6 -5
- package/dist/runtime.cjs +2113 -597
- package/dist/runtime.d.cts +44 -25
- package/dist/runtime.d.ts +44 -25
- package/dist/runtime.js +11 -11
- package/dist/{schemas-BgJhlqqO.d.cts → schemas-BKnh4fKR.d.cts} +209 -24
- package/dist/{schemas-C4mK1WSs.d.ts → schemas-CDa3V_I5.d.ts} +209 -24
- package/dist/{sdk-BTciSLRQ.d.cts → sdk-CXNqzoY1.d.cts} +1 -1
- package/dist/{sdk-qKJtMma_.d.ts → sdk-Cin80BTC.d.ts} +1 -1
- package/dist/{simpleAdapter-BpT4XbZC.d.cts → simpleAdapter-D75rcz9n.d.cts} +3 -1
- package/dist/{simpleAdapter--wznuoOw.d.ts → simpleAdapter-eLvsAQNo.d.ts} +3 -1
- package/dist/testing.cjs +25 -7
- package/dist/testing.d.cts +5 -3
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +9 -6
- package/dist/{types-BivGO7I9.d.ts → types-42-HWeX8.d.cts} +10 -4
- package/dist/types-CKU1N0SK.d.ts +137 -0
- package/dist/{types-BgYFd_Yw.d.cts → types-CjxZekMH.d.ts} +10 -4
- package/dist/types-DtcOLALn.d.cts +137 -0
- package/package.json +25 -26
- package/dist/acp-server-CiUqN3G3.d.ts +0 -24
- package/dist/acp-server-Dlj7D563.d.cts +0 -24
- package/dist/chunk-AVVDPXP4.js +0 -39
- package/dist/chunk-F63TPOI3.js +0 -29
- package/dist/chunk-V5TSWS7P.js +0 -62
- package/dist/types-BNNtOXu3.d.cts +0 -446
- package/dist/types-DFUtpxah.d.ts +0 -446
|
@@ -4,21 +4,22 @@ import {
|
|
|
4
4
|
SYNTHETIC_CONTACT_EVENTS_SENDER_NAME,
|
|
5
5
|
SYNTHETIC_SENDER_TYPE,
|
|
6
6
|
buildParticipantsMessage,
|
|
7
|
+
createDeferred,
|
|
7
8
|
toParticipantRecord,
|
|
8
9
|
toParticipantRecordFromRest
|
|
9
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-CBCHTTUY.js";
|
|
10
11
|
import {
|
|
11
12
|
CHAT_TOOL_NAMES,
|
|
12
13
|
MEMORY_LIST_SCOPES,
|
|
13
14
|
MEMORY_SEGMENTS,
|
|
14
15
|
MEMORY_STATUSES,
|
|
16
|
+
MEMORY_STORE_SCOPE,
|
|
15
17
|
MEMORY_STORE_SCOPES,
|
|
16
18
|
MEMORY_SYSTEMS,
|
|
17
19
|
MEMORY_TOOL_NAMES,
|
|
18
20
|
MEMORY_TYPES,
|
|
19
21
|
TOOL_MODELS,
|
|
20
22
|
expectedList,
|
|
21
|
-
expectedMemoryTypesForSystem,
|
|
22
23
|
getToolDescription,
|
|
23
24
|
isMemoryListScope,
|
|
24
25
|
isMemorySegment,
|
|
@@ -28,37 +29,148 @@ import {
|
|
|
28
29
|
isMemoryType,
|
|
29
30
|
isMemoryTypeForSystem,
|
|
30
31
|
memoryTypeForSystemError
|
|
31
|
-
} from "./chunk-
|
|
32
|
-
import {
|
|
33
|
-
CHAT_EVENT_TYPES,
|
|
34
|
-
assertChatEventType
|
|
35
|
-
} from "./chunk-F63TPOI3.js";
|
|
32
|
+
} from "./chunk-SHZBLLDQ.js";
|
|
36
33
|
import {
|
|
37
34
|
WebSocketDisconnectError,
|
|
38
35
|
genericCloseReason,
|
|
39
36
|
parseSupersedeDisconnectReason,
|
|
40
37
|
parseUpgradeDisconnectReason
|
|
41
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-UENIC5FJ.js";
|
|
42
39
|
import {
|
|
43
40
|
DEFAULT_REQUEST_OPTIONS,
|
|
44
41
|
FernRestAdapter,
|
|
45
42
|
fetchPaginated
|
|
46
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-OVXVZUPO.js";
|
|
44
|
+
import {
|
|
45
|
+
asErrorMessage
|
|
46
|
+
} from "./chunk-6BE2EG5P.js";
|
|
47
47
|
import {
|
|
48
|
+
CHAT_EVENT_TYPES,
|
|
48
49
|
DEFAULT_AGENT_TOOLS_CAPABILITIES,
|
|
50
|
+
assertChatEventType,
|
|
49
51
|
createToolExecutorError,
|
|
50
52
|
isStructuredToolFailure,
|
|
51
|
-
isToolExecutorError
|
|
52
|
-
|
|
53
|
+
isToolExecutorError,
|
|
54
|
+
sendFailureViaEvent
|
|
55
|
+
} from "./chunk-TGDDFBYS.js";
|
|
53
56
|
import {
|
|
54
|
-
|
|
55
|
-
} from "./chunk-
|
|
57
|
+
resolveLogger
|
|
58
|
+
} from "./chunk-ZYOXI4JL.js";
|
|
56
59
|
import {
|
|
60
|
+
RecoverableTurnError,
|
|
57
61
|
RuntimeStateError,
|
|
58
62
|
TransportError,
|
|
59
63
|
UnsupportedFeatureError,
|
|
60
64
|
ValidationError
|
|
61
|
-
} from "./chunk-
|
|
65
|
+
} from "./chunk-4OVNFDD7.js";
|
|
66
|
+
|
|
67
|
+
// src/core/singleFlight.ts
|
|
68
|
+
var SingleFlight = class {
|
|
69
|
+
pending = null;
|
|
70
|
+
/** The currently in-flight operation's promise, if any is running. */
|
|
71
|
+
get current() {
|
|
72
|
+
return this.pending;
|
|
73
|
+
}
|
|
74
|
+
run(start) {
|
|
75
|
+
if (!this.pending) {
|
|
76
|
+
const promise = start();
|
|
77
|
+
this.pending = promise;
|
|
78
|
+
const clear = () => {
|
|
79
|
+
if (this.pending === promise) {
|
|
80
|
+
this.pending = null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
void promise.then(clear, clear);
|
|
84
|
+
}
|
|
85
|
+
return this.pending;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var KeyedSingleFlight = class {
|
|
89
|
+
flights = /* @__PURE__ */ new Map();
|
|
90
|
+
/** The currently in-flight operation's promise for `key`, if any is running. */
|
|
91
|
+
current(key) {
|
|
92
|
+
return this.flights.get(key)?.current ?? null;
|
|
93
|
+
}
|
|
94
|
+
run(key, start) {
|
|
95
|
+
let flight = this.flights.get(key);
|
|
96
|
+
if (!flight) {
|
|
97
|
+
flight = new SingleFlight();
|
|
98
|
+
this.flights.set(key, flight);
|
|
99
|
+
}
|
|
100
|
+
const promise = flight.run(start);
|
|
101
|
+
const forget = () => {
|
|
102
|
+
if (this.flights.get(key) === flight && !flight.current) {
|
|
103
|
+
this.flights.delete(key);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
void promise.then(forget, forget);
|
|
107
|
+
return promise;
|
|
108
|
+
}
|
|
109
|
+
/** Drops every key without waiting for its in-flight operation to settle. */
|
|
110
|
+
clear() {
|
|
111
|
+
this.flights.clear();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var Serializer = class {
|
|
115
|
+
tail = Promise.resolve();
|
|
116
|
+
run(body) {
|
|
117
|
+
const run = this.tail.then(body, body);
|
|
118
|
+
this.tail = run.then(
|
|
119
|
+
() => void 0,
|
|
120
|
+
() => void 0
|
|
121
|
+
);
|
|
122
|
+
return run;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/core/epoch.ts
|
|
127
|
+
var Epoch = class {
|
|
128
|
+
value = 0;
|
|
129
|
+
get current() {
|
|
130
|
+
return this.value;
|
|
131
|
+
}
|
|
132
|
+
/** Advances to the next generation and returns it. */
|
|
133
|
+
bump() {
|
|
134
|
+
this.value += 1;
|
|
135
|
+
return this.value;
|
|
136
|
+
}
|
|
137
|
+
isStale(captured) {
|
|
138
|
+
return captured !== this.value;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/platform/Session.ts
|
|
143
|
+
var Session = class {
|
|
144
|
+
epoch = new Epoch();
|
|
145
|
+
active = false;
|
|
146
|
+
/**
|
|
147
|
+
* Teardown for whatever reconnect observer this session registered on the
|
|
148
|
+
* transport — a plain field since a caller just assigns whatever
|
|
149
|
+
* `onReconnected()` handed back (or `null` if the transport has none).
|
|
150
|
+
*/
|
|
151
|
+
reconnectObserverTeardown = null;
|
|
152
|
+
get isActive() {
|
|
153
|
+
return this.active;
|
|
154
|
+
}
|
|
155
|
+
isStale(epoch) {
|
|
156
|
+
return this.epoch.isStale(epoch);
|
|
157
|
+
}
|
|
158
|
+
/** Starts a new session and returns its epoch. */
|
|
159
|
+
begin() {
|
|
160
|
+
const epoch = this.epoch.bump();
|
|
161
|
+
this.active = true;
|
|
162
|
+
return epoch;
|
|
163
|
+
}
|
|
164
|
+
/** Marks the session inactive and advances the epoch, so any work still in flight for it is now stale. */
|
|
165
|
+
deactivate() {
|
|
166
|
+
this.active = false;
|
|
167
|
+
this.epoch.bump();
|
|
168
|
+
}
|
|
169
|
+
clearReconnectObserver() {
|
|
170
|
+
this.reconnectObserverTeardown?.();
|
|
171
|
+
this.reconnectObserverTeardown = null;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
62
174
|
|
|
63
175
|
// src/contracts/capabilities.ts
|
|
64
176
|
var CAPABILITY_LABELS = {
|
|
@@ -72,104 +184,213 @@ function assertCapability(capabilities, capability, label = CAPABILITY_LABELS[ca
|
|
|
72
184
|
}
|
|
73
185
|
}
|
|
74
186
|
|
|
75
|
-
// src/platform/streaming/payloadSchemas.ts
|
|
76
|
-
import { z } from "zod";
|
|
77
|
-
var mentionSchema = z.object({
|
|
78
|
-
id: z.string(),
|
|
79
|
-
handle: z.string().nullish(),
|
|
80
|
-
name: z.string().nullish(),
|
|
81
|
-
username: z.string().nullish()
|
|
82
|
-
}).passthrough();
|
|
83
|
-
var messageMetadataSchema = z.object({
|
|
84
|
-
mentions: z.array(mentionSchema).nullish()
|
|
85
|
-
}).passthrough();
|
|
86
|
-
var messageCreatedPayloadSchema = z.object({
|
|
87
|
-
id: z.string(),
|
|
88
|
-
content: z.string(),
|
|
89
|
-
message_type: z.string(),
|
|
90
|
-
metadata: messageMetadataSchema.nullish(),
|
|
91
|
-
sender_id: z.string(),
|
|
92
|
-
sender_type: z.string(),
|
|
93
|
-
sender_name: z.string().nullish(),
|
|
94
|
-
chat_room_id: z.string().nullish(),
|
|
95
|
-
inserted_at: z.string(),
|
|
96
|
-
updated_at: z.string()
|
|
97
|
-
}).passthrough();
|
|
98
|
-
var roomOwnerSchema = z.object({
|
|
99
|
-
id: z.string(),
|
|
100
|
-
name: z.string(),
|
|
101
|
-
type: z.string()
|
|
102
|
-
}).passthrough();
|
|
103
|
-
var roomAddedPayloadSchema = z.object({
|
|
104
|
-
id: z.string(),
|
|
105
|
-
title: z.string().nullish(),
|
|
106
|
-
task_id: z.string().nullish(),
|
|
107
|
-
inserted_at: z.string().nullish(),
|
|
108
|
-
updated_at: z.string().nullish(),
|
|
109
|
-
owner: roomOwnerSchema.nullish(),
|
|
110
|
-
status: z.string().nullish(),
|
|
111
|
-
type: z.string().nullish(),
|
|
112
|
-
created_at: z.string().nullish(),
|
|
113
|
-
participant_role: z.string().nullish()
|
|
114
|
-
}).passthrough();
|
|
115
|
-
var roomRemovedPayloadSchema = z.object({
|
|
116
|
-
id: z.string(),
|
|
117
|
-
status: z.string(),
|
|
118
|
-
type: z.string(),
|
|
119
|
-
title: z.string(),
|
|
120
|
-
removed_at: z.string()
|
|
121
|
-
}).passthrough();
|
|
122
|
-
var participantAddedPayloadSchema = z.object({
|
|
123
|
-
id: z.string(),
|
|
124
|
-
name: z.string(),
|
|
125
|
-
type: z.string(),
|
|
126
|
-
handle: z.string().nullish()
|
|
127
|
-
}).passthrough();
|
|
128
|
-
var participantRemovedPayloadSchema = z.object({
|
|
129
|
-
id: z.string()
|
|
130
|
-
}).passthrough();
|
|
131
|
-
var roomDeletedPayloadSchema = z.object({
|
|
132
|
-
id: z.string()
|
|
133
|
-
}).passthrough();
|
|
134
|
-
var contactRequestReceivedPayloadSchema = z.object({
|
|
135
|
-
id: z.string(),
|
|
136
|
-
from_handle: z.string(),
|
|
137
|
-
from_name: z.string(),
|
|
138
|
-
message: z.string().nullish(),
|
|
139
|
-
status: z.string(),
|
|
140
|
-
inserted_at: z.string()
|
|
141
|
-
}).passthrough();
|
|
142
|
-
var contactRequestUpdatedPayloadSchema = z.object({
|
|
143
|
-
id: z.string(),
|
|
144
|
-
status: z.string()
|
|
145
|
-
}).passthrough();
|
|
146
|
-
var contactAddedPayloadSchema = z.object({
|
|
147
|
-
id: z.string(),
|
|
148
|
-
handle: z.string(),
|
|
149
|
-
name: z.string(),
|
|
150
|
-
type: z.string(),
|
|
151
|
-
description: z.string().nullish(),
|
|
152
|
-
is_external: z.boolean().nullish(),
|
|
153
|
-
inserted_at: z.string()
|
|
154
|
-
}).passthrough();
|
|
155
|
-
var contactRemovedPayloadSchema = z.object({
|
|
156
|
-
id: z.string()
|
|
157
|
-
}).passthrough();
|
|
158
|
-
var payloadSchemas = {
|
|
159
|
-
message_created: messageCreatedPayloadSchema,
|
|
160
|
-
room_added: roomAddedPayloadSchema,
|
|
161
|
-
room_removed: roomRemovedPayloadSchema,
|
|
162
|
-
participant_added: participantAddedPayloadSchema,
|
|
163
|
-
participant_removed: participantRemovedPayloadSchema,
|
|
164
|
-
room_deleted: roomDeletedPayloadSchema,
|
|
165
|
-
contact_request_received: contactRequestReceivedPayloadSchema,
|
|
166
|
-
contact_request_updated: contactRequestUpdatedPayloadSchema,
|
|
167
|
-
contact_added: contactAddedPayloadSchema,
|
|
168
|
-
contact_removed: contactRemovedPayloadSchema
|
|
169
|
-
};
|
|
170
|
-
|
|
171
187
|
// src/platform/streaming/PhoenixChannelsTransport.ts
|
|
172
|
-
import { Socket } from "phoenix";
|
|
188
|
+
import { Socket as Socket2 } from "phoenix";
|
|
189
|
+
|
|
190
|
+
// src/core/teardown.ts
|
|
191
|
+
async function isolateTeardown(errors, step) {
|
|
192
|
+
try {
|
|
193
|
+
await step();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
errors.push(error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function combineTeardownErrors(errors, message) {
|
|
199
|
+
const distinct = [...new Set(errors)];
|
|
200
|
+
return distinct.length === 1 ? distinct[0] : new AggregateError(distinct, message);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/platform/streaming/ChannelRegistry.ts
|
|
204
|
+
import "phoenix";
|
|
205
|
+
function supersededJoinError(topic) {
|
|
206
|
+
return new TransportError(`Join superseded by transport disconnect for topic ${topic}`);
|
|
207
|
+
}
|
|
208
|
+
var ChannelRegistry = class {
|
|
209
|
+
constructor(socket, epoch, logger, hooks) {
|
|
210
|
+
this.socket = socket;
|
|
211
|
+
this.epoch = epoch;
|
|
212
|
+
this.logger = logger;
|
|
213
|
+
this.hooks = hooks;
|
|
214
|
+
}
|
|
215
|
+
channels = /* @__PURE__ */ new Map();
|
|
216
|
+
// A join's Channel and handler bindings, tracked from the moment doJoin
|
|
217
|
+
// creates them — before the join Push settles — so a teardown mid-join can
|
|
218
|
+
// find and detach it too, not only joins already promoted into `channels`.
|
|
219
|
+
// Left untracked here, the underlying Phoenix Channel would survive
|
|
220
|
+
// teardown unnoticed, keep its handlers bound, and could later be
|
|
221
|
+
// resurrected by Phoenix's own reconnect machinery, redelivering live
|
|
222
|
+
// events with no dedup anywhere upstream.
|
|
223
|
+
pendingChannels = /* @__PURE__ */ new Map();
|
|
224
|
+
joinFlights = new KeyedSingleFlight();
|
|
225
|
+
leaveFlights = new KeyedSingleFlight();
|
|
226
|
+
topics() {
|
|
227
|
+
return this.channels.keys();
|
|
228
|
+
}
|
|
229
|
+
isJoined(topic) {
|
|
230
|
+
return this.channels.has(topic);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A promise for `topic` if it's already joined or has a join in flight,
|
|
234
|
+
* without starting a new one. A topic mid-leave is never reported as
|
|
235
|
+
* already joined — `channels` still holds it until the leave's Push
|
|
236
|
+
* settles, but its handlers are already unbound and the channel is about
|
|
237
|
+
* to be removed, so treating that window as "joined" would hand the
|
|
238
|
+
* caller a promise that resolves into a channel already gone.
|
|
239
|
+
*/
|
|
240
|
+
existingJoin(topic) {
|
|
241
|
+
if (this.channels.has(topic) && !this.leaveFlights.current(topic)) {
|
|
242
|
+
return Promise.resolve();
|
|
243
|
+
}
|
|
244
|
+
return this.joinFlights.current(topic) ?? void 0;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Starts a new join for `topic`. Callers check `existingJoin()` first.
|
|
248
|
+
* Waits out a leave already in flight for the same topic before starting,
|
|
249
|
+
* so the new join's channel is never raced by the old one's teardown.
|
|
250
|
+
*/
|
|
251
|
+
async join(topic, handlers) {
|
|
252
|
+
const pendingLeave = this.leaveFlights.current(topic);
|
|
253
|
+
if (pendingLeave) {
|
|
254
|
+
await pendingLeave.catch(() => void 0);
|
|
255
|
+
}
|
|
256
|
+
return this.joinFlights.run(topic, () => this.doJoin(topic, handlers));
|
|
257
|
+
}
|
|
258
|
+
async doJoin(topic, handlers) {
|
|
259
|
+
const epoch = this.epoch.current;
|
|
260
|
+
const channel = this.socket.channel(topic, {});
|
|
261
|
+
const refs = [];
|
|
262
|
+
for (const [event, handler] of Object.entries(handlers)) {
|
|
263
|
+
const ref = channel.on(event, this.hooks.wrapHandler(topic, event, handler));
|
|
264
|
+
refs.push([event, ref]);
|
|
265
|
+
}
|
|
266
|
+
const joinPush = channel.join();
|
|
267
|
+
try {
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
this.pendingChannels.set(topic, { channel, refs, reject });
|
|
270
|
+
joinPush.receive("ok", () => {
|
|
271
|
+
this.hooks.onJoinSettled(topic, true);
|
|
272
|
+
resolve();
|
|
273
|
+
}).receive("error", (error) => {
|
|
274
|
+
this.hooks.onJoinSettled(topic, false);
|
|
275
|
+
reject(new TransportError(`Failed to join topic ${topic}`, error));
|
|
276
|
+
}).receive("timeout", () => {
|
|
277
|
+
this.hooks.onJoinSettled(topic, false);
|
|
278
|
+
reject(new TransportError(`Timeout joining topic ${topic}`));
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const pending2 = this.forgetPendingChannel(topic, channel);
|
|
283
|
+
if (pending2) {
|
|
284
|
+
this.abandonChannel(topic, pending2);
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
const pending = this.forgetPendingChannel(topic, channel);
|
|
289
|
+
if (!pending || this.epoch.isStale(epoch)) {
|
|
290
|
+
if (pending) {
|
|
291
|
+
this.abandonChannel(topic, pending);
|
|
292
|
+
}
|
|
293
|
+
this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
|
|
294
|
+
throw supersededJoinError(topic);
|
|
295
|
+
}
|
|
296
|
+
this.channels.set(topic, { channel, refs });
|
|
297
|
+
this.logger.debug("Joined topic", { topic });
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Removes and returns `topic`'s pendingChannels entry only if it still
|
|
301
|
+
* points at `channel`. A topic-keyed delete without this identity check
|
|
302
|
+
* can drop a *different*, still-genuinely-pending join for the same topic
|
|
303
|
+
* — reachable because a teardown clears `joinFlights`, so a later join for
|
|
304
|
+
* a topic whose earlier join is still unsettled is possible, and that
|
|
305
|
+
* earlier join's eventual (stale) settlement must not touch a slot it no
|
|
306
|
+
* longer owns.
|
|
307
|
+
*/
|
|
308
|
+
forgetPendingChannel(topic, channel) {
|
|
309
|
+
const pending = this.pendingChannels.get(topic);
|
|
310
|
+
if (pending?.channel !== channel) {
|
|
311
|
+
return void 0;
|
|
312
|
+
}
|
|
313
|
+
this.pendingChannels.delete(topic);
|
|
314
|
+
return pending;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Detaches a join's channel and — via `pending.reject` — settles its
|
|
318
|
+
* still-outstanding join promise directly, so abandoning it can never
|
|
319
|
+
* leave `doJoin()`'s awaiter hanging on a Phoenix reply that a `leave()`
|
|
320
|
+
* just made unroutable.
|
|
321
|
+
*/
|
|
322
|
+
abandonChannel(topic, pending) {
|
|
323
|
+
for (const [event, ref] of pending.refs) {
|
|
324
|
+
pending.channel.off(event, ref);
|
|
325
|
+
}
|
|
326
|
+
pending.channel.leave();
|
|
327
|
+
removeSocketChannel(this.socket, pending.channel);
|
|
328
|
+
pending.reject(supersededJoinError(topic));
|
|
329
|
+
}
|
|
330
|
+
async leave(topic) {
|
|
331
|
+
const pendingLeave = this.leaveFlights.current(topic);
|
|
332
|
+
if (pendingLeave) {
|
|
333
|
+
return pendingLeave;
|
|
334
|
+
}
|
|
335
|
+
const tracked = this.channels.get(topic);
|
|
336
|
+
if (!tracked) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
return this.leaveFlights.run(topic, () => this.doLeave(topic, tracked));
|
|
340
|
+
}
|
|
341
|
+
async doLeave(topic, tracked) {
|
|
342
|
+
const { channel, refs } = tracked;
|
|
343
|
+
for (const [event, ref] of refs) {
|
|
344
|
+
channel.off(event, ref);
|
|
345
|
+
}
|
|
346
|
+
await new Promise((resolve, reject) => {
|
|
347
|
+
channel.leave().receive("ok", () => resolve()).receive(
|
|
348
|
+
"error",
|
|
349
|
+
(error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
|
|
350
|
+
).receive(
|
|
351
|
+
"timeout",
|
|
352
|
+
() => reject(new TransportError(`Timeout leaving topic ${topic}`))
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
this.channels.delete(topic);
|
|
356
|
+
this.hooks.onLeft(topic);
|
|
357
|
+
this.logger.debug("Left topic", { topic });
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Attempts a graceful leave for every currently joined topic. A leave that
|
|
361
|
+
* fails leaves its channel registered until `forceTeardown()` sweeps it.
|
|
362
|
+
* Returns the rejection reasons of any leaves that failed.
|
|
363
|
+
*/
|
|
364
|
+
async leaveAll() {
|
|
365
|
+
const topics = [...this.channels.keys()];
|
|
366
|
+
const results = await Promise.allSettled(topics.map((topic) => this.leave(topic)));
|
|
367
|
+
return results.filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Forcibly detaches anything still registered — a leave that failed above,
|
|
371
|
+
* or a join still in flight — and clears all coalescing state. Call after
|
|
372
|
+
* `leaveAll()` has settled.
|
|
373
|
+
*/
|
|
374
|
+
forceTeardown() {
|
|
375
|
+
for (const { channel, refs } of this.channels.values()) {
|
|
376
|
+
for (const [event, ref] of refs) {
|
|
377
|
+
channel.off(event, ref);
|
|
378
|
+
}
|
|
379
|
+
removeSocketChannel(this.socket, channel);
|
|
380
|
+
}
|
|
381
|
+
this.channels.clear();
|
|
382
|
+
for (const [topic, pending] of this.pendingChannels) {
|
|
383
|
+
this.abandonChannel(topic, pending);
|
|
384
|
+
}
|
|
385
|
+
this.pendingChannels.clear();
|
|
386
|
+
this.joinFlights.clear();
|
|
387
|
+
this.leaveFlights.clear();
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
function removeSocketChannel(socket, channel) {
|
|
391
|
+
const candidate = socket;
|
|
392
|
+
candidate.remove?.(channel);
|
|
393
|
+
}
|
|
173
394
|
|
|
174
395
|
// src/platform/streaming/nodeWebSocketFactory.ts
|
|
175
396
|
import { WebSocket as NodeWebSocket } from "ws";
|
|
@@ -206,19 +427,90 @@ async function readResponseBody(response) {
|
|
|
206
427
|
return Buffer.concat(chunks).toString("utf8");
|
|
207
428
|
}
|
|
208
429
|
|
|
430
|
+
// src/platform/streaming/ReconnectGenerationTracker.ts
|
|
431
|
+
var ReconnectGenerationTracker = class {
|
|
432
|
+
constructor(onSettled, onGenerationDropped) {
|
|
433
|
+
this.onSettled = onSettled;
|
|
434
|
+
this.onGenerationDropped = onGenerationDropped;
|
|
435
|
+
}
|
|
436
|
+
generations = /* @__PURE__ */ new Map();
|
|
437
|
+
currentGeneration = 0;
|
|
438
|
+
beginGeneration(topics) {
|
|
439
|
+
const generation = ++this.currentGeneration;
|
|
440
|
+
const attempted = new Set(topics);
|
|
441
|
+
for (const staleGeneration of this.generations.keys()) {
|
|
442
|
+
if (staleGeneration < generation) {
|
|
443
|
+
const stale = this.generations.get(staleGeneration);
|
|
444
|
+
if (stale) {
|
|
445
|
+
this.onGenerationDropped?.(staleGeneration, stale.pending.size);
|
|
446
|
+
this.generations.delete(staleGeneration);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
this.generations.set(generation, { pending: new Set(attempted), attempted, joined: /* @__PURE__ */ new Set() });
|
|
451
|
+
this.maybeFinalize(generation);
|
|
452
|
+
return generation;
|
|
453
|
+
}
|
|
454
|
+
recordSettled(topic, joined) {
|
|
455
|
+
const record = this.generations.get(this.currentGeneration);
|
|
456
|
+
if (!record?.pending.delete(topic)) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (joined) {
|
|
460
|
+
record.joined.add(topic);
|
|
461
|
+
}
|
|
462
|
+
this.maybeFinalize(this.currentGeneration);
|
|
463
|
+
}
|
|
464
|
+
removeTopic(topic) {
|
|
465
|
+
for (const [generation, record] of this.generations) {
|
|
466
|
+
if (record.pending.delete(topic)) {
|
|
467
|
+
this.maybeFinalize(generation);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
reset() {
|
|
472
|
+
this.generations.clear();
|
|
473
|
+
this.currentGeneration = 0;
|
|
474
|
+
}
|
|
475
|
+
maybeFinalize(generation) {
|
|
476
|
+
const record = this.generations.get(generation);
|
|
477
|
+
if (!record || record.pending.size > 0) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.generations.delete(generation);
|
|
481
|
+
this.onSettled({ generation, attemptedTopics: record.attempted, joinedTopics: record.joined });
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
209
485
|
// src/platform/streaming/PhoenixChannelsTransport.ts
|
|
210
486
|
import { agentControlTopic } from "@band-ai/band-sdk-core";
|
|
211
487
|
var PhoenixChannelsTransport = class {
|
|
212
488
|
socket;
|
|
213
489
|
agentId;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
490
|
+
registry;
|
|
491
|
+
reconnectObservers = /* @__PURE__ */ new Set();
|
|
492
|
+
// Topics joined with `{ exemptFromBuffering: true }`, recorded here so
|
|
493
|
+
// `wrapHandler` can check by name on every delivered event rather than
|
|
494
|
+
// threading the flag through the channel/handler plumbing.
|
|
495
|
+
bufferingExemptTopics = /* @__PURE__ */ new Set();
|
|
496
|
+
generationTracker = new ReconnectGenerationTracker(
|
|
497
|
+
(snapshot) => this.notifyReconnectObservers(snapshot),
|
|
498
|
+
(generation, pendingTopics) => this.logger.debug("Superseded reconnect generation before it fully settled", {
|
|
499
|
+
generation,
|
|
500
|
+
pendingTopics
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
bufferedTopicEvents = [];
|
|
504
|
+
hasOpenedOnce = false;
|
|
505
|
+
epoch = new Epoch();
|
|
506
|
+
bufferingGeneration = null;
|
|
507
|
+
reconnectBarrier = null;
|
|
508
|
+
observerChain = new Serializer();
|
|
217
509
|
logger;
|
|
218
510
|
onTerminalDisconnect;
|
|
219
511
|
onHandlerError;
|
|
220
512
|
connected = false;
|
|
221
|
-
|
|
513
|
+
connectFlight = new SingleFlight();
|
|
222
514
|
connectResolve = null;
|
|
223
515
|
connectReject = null;
|
|
224
516
|
lastDisconnectReason = null;
|
|
@@ -227,7 +519,7 @@ var PhoenixChannelsTransport = class {
|
|
|
227
519
|
stoppingReconnect = false;
|
|
228
520
|
suppressNextCloseReason = false;
|
|
229
521
|
constructor(options) {
|
|
230
|
-
this.logger = options.logger
|
|
522
|
+
this.logger = resolveLogger(options.logger);
|
|
231
523
|
this.agentId = options.agentId;
|
|
232
524
|
this.onTerminalDisconnect = options.onTerminalDisconnect;
|
|
233
525
|
let wsUrl = options.wsUrl;
|
|
@@ -235,7 +527,7 @@ var PhoenixChannelsTransport = class {
|
|
|
235
527
|
wsUrl = wsUrl.slice(0, -"/websocket".length);
|
|
236
528
|
}
|
|
237
529
|
const reconnectAfterMs = options.reconnectAfterMs ?? ((tries) => [1e3, 2e3, 5e3, 1e4, 3e4][tries - 1] ?? 3e4);
|
|
238
|
-
this.socket = new
|
|
530
|
+
this.socket = new Socket2(wsUrl, {
|
|
239
531
|
params: {
|
|
240
532
|
agent_id: options.agentId,
|
|
241
533
|
...options.conflictPolicy ? { on_conflict: options.conflictPolicy } : {}
|
|
@@ -249,6 +541,35 @@ var PhoenixChannelsTransport = class {
|
|
|
249
541
|
},
|
|
250
542
|
transport: options.websocketFactory ?? resolveWebSocketFactory(options.apiKey)
|
|
251
543
|
});
|
|
544
|
+
this.registry = new ChannelRegistry(this.socket, this.epoch, this.logger, {
|
|
545
|
+
wrapHandler: (topic, event, handler) => (payload) => {
|
|
546
|
+
const reportError = (error) => {
|
|
547
|
+
this.logger.error("Unhandled topic handler error", { topic, event, error });
|
|
548
|
+
this.onHandlerError?.(error);
|
|
549
|
+
};
|
|
550
|
+
const deliver = () => {
|
|
551
|
+
try {
|
|
552
|
+
void Promise.resolve(handler(payload)).catch(reportError);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
reportError(error);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
if (this.bufferingGeneration !== null && !this.bufferingExemptTopics.has(topic)) {
|
|
558
|
+
this.bufferedTopicEvents.push({ topic, deliver });
|
|
559
|
+
} else {
|
|
560
|
+
deliver();
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
onJoinSettled: (topic, joined) => this.generationTracker.recordSettled(topic, joined),
|
|
564
|
+
onLeft: (topic) => {
|
|
565
|
+
this.generationTracker.removeTopic(topic);
|
|
566
|
+
for (let index = this.bufferedTopicEvents.length - 1; index >= 0; index -= 1) {
|
|
567
|
+
if (this.bufferedTopicEvents[index]?.topic === topic) {
|
|
568
|
+
this.bufferedTopicEvents.splice(index, 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
});
|
|
252
573
|
this.socket.onOpen(() => {
|
|
253
574
|
void this.handleOpen();
|
|
254
575
|
});
|
|
@@ -285,42 +606,25 @@ var PhoenixChannelsTransport = class {
|
|
|
285
606
|
if (this.connected) {
|
|
286
607
|
return;
|
|
287
608
|
}
|
|
288
|
-
|
|
609
|
+
await this.connectFlight.run(() => {
|
|
289
610
|
this.socket.connect();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
void pending.then(
|
|
293
|
-
() => {
|
|
294
|
-
if (this.connectPromise === pending) {
|
|
295
|
-
this.connectPromise = null;
|
|
296
|
-
}
|
|
297
|
-
},
|
|
298
|
-
() => {
|
|
299
|
-
if (this.connectPromise === pending) {
|
|
300
|
-
this.connectPromise = null;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
await this.connectPromise;
|
|
611
|
+
return this.waitForConnection();
|
|
612
|
+
});
|
|
306
613
|
}
|
|
307
614
|
async disconnect() {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
615
|
+
this.epoch.bump();
|
|
616
|
+
const failures = await this.registry.leaveAll();
|
|
311
617
|
this.socket.disconnect();
|
|
312
618
|
this.connected = false;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
619
|
+
this.registry.forceTeardown();
|
|
620
|
+
this.hasOpenedOnce = false;
|
|
621
|
+
this.bufferingGeneration = null;
|
|
622
|
+
this.reconnectBarrier?.resolve();
|
|
623
|
+
this.reconnectBarrier = null;
|
|
624
|
+
this.bufferedTopicEvents.splice(0);
|
|
625
|
+
this.generationTracker.reset();
|
|
319
626
|
if (failures.length > 0) {
|
|
320
|
-
throw
|
|
321
|
-
failures,
|
|
322
|
-
"Failed to leave one or more Phoenix topics during disconnect"
|
|
323
|
-
);
|
|
627
|
+
throw combineTeardownErrors(failures, "Failed to leave one or more Phoenix topics during disconnect");
|
|
324
628
|
}
|
|
325
629
|
}
|
|
326
630
|
isConnected() {
|
|
@@ -329,79 +633,28 @@ var PhoenixChannelsTransport = class {
|
|
|
329
633
|
getDisconnectReason() {
|
|
330
634
|
return this.lastDisconnectReason;
|
|
331
635
|
}
|
|
332
|
-
async join(topic, handlers) {
|
|
333
|
-
if (
|
|
334
|
-
|
|
636
|
+
async join(topic, handlers, options) {
|
|
637
|
+
if (options?.exemptFromBuffering) {
|
|
638
|
+
this.bufferingExemptTopics.add(topic);
|
|
335
639
|
}
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
return
|
|
640
|
+
const existing = this.registry.existingJoin(topic);
|
|
641
|
+
if (existing) {
|
|
642
|
+
return existing;
|
|
339
643
|
}
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
async doJoin(topic, handlers) {
|
|
347
|
-
const channel = this.socket.channel(topic, {});
|
|
348
|
-
const refs = [];
|
|
349
|
-
for (const [event, handler] of Object.entries(handlers)) {
|
|
350
|
-
const ref = channel.on(event, (payload) => {
|
|
351
|
-
Promise.resolve(handler(payload)).catch((error) => {
|
|
352
|
-
this.logger.error("Unhandled topic handler error", {
|
|
353
|
-
topic,
|
|
354
|
-
event,
|
|
355
|
-
error
|
|
356
|
-
});
|
|
357
|
-
this.onHandlerError?.(error);
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
refs.push([event, ref]);
|
|
644
|
+
const epoch = this.epoch.current;
|
|
645
|
+
await this.reconnectBarrier?.promise;
|
|
646
|
+
if (this.epoch.isStale(epoch)) {
|
|
647
|
+
this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
|
|
648
|
+
throw supersededJoinError(topic);
|
|
361
649
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
"error",
|
|
366
|
-
(error) => reject(new TransportError(`Failed to join topic ${topic}`, error))
|
|
367
|
-
).receive(
|
|
368
|
-
"timeout",
|
|
369
|
-
() => reject(new TransportError(`Timeout joining topic ${topic}`))
|
|
370
|
-
);
|
|
371
|
-
});
|
|
372
|
-
} catch (error) {
|
|
373
|
-
for (const [event, ref] of refs) {
|
|
374
|
-
channel.off(event, ref);
|
|
375
|
-
}
|
|
376
|
-
channel.leave();
|
|
377
|
-
removeSocketChannel(this.socket, channel);
|
|
378
|
-
throw error;
|
|
650
|
+
const resumed = this.registry.existingJoin(topic);
|
|
651
|
+
if (resumed) {
|
|
652
|
+
return resumed;
|
|
379
653
|
}
|
|
380
|
-
this.
|
|
381
|
-
this.channelRefs.set(topic, refs);
|
|
382
|
-
this.logger.debug("Joined topic", { topic });
|
|
654
|
+
return this.registry.join(topic, handlers);
|
|
383
655
|
}
|
|
384
656
|
async leave(topic) {
|
|
385
|
-
|
|
386
|
-
if (!channel) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const refs = this.channelRefs.get(topic) ?? [];
|
|
390
|
-
for (const [event, ref] of refs) {
|
|
391
|
-
channel.off(event, ref);
|
|
392
|
-
}
|
|
393
|
-
this.channelRefs.delete(topic);
|
|
394
|
-
await new Promise((resolve, reject) => {
|
|
395
|
-
channel.leave().receive("ok", () => resolve()).receive(
|
|
396
|
-
"error",
|
|
397
|
-
(error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
|
|
398
|
-
).receive(
|
|
399
|
-
"timeout",
|
|
400
|
-
() => reject(new TransportError(`Timeout leaving topic ${topic}`))
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
this.channels.delete(topic);
|
|
404
|
-
this.logger.debug("Left topic", { topic });
|
|
657
|
+
return this.registry.leave(topic);
|
|
405
658
|
}
|
|
406
659
|
async runForever(signal) {
|
|
407
660
|
if (this.terminalDisconnectError) {
|
|
@@ -439,7 +692,21 @@ var PhoenixChannelsTransport = class {
|
|
|
439
692
|
);
|
|
440
693
|
});
|
|
441
694
|
}
|
|
695
|
+
onReconnected(observer) {
|
|
696
|
+
this.reconnectObservers.add(observer);
|
|
697
|
+
return () => {
|
|
698
|
+
this.reconnectObservers.delete(observer);
|
|
699
|
+
};
|
|
700
|
+
}
|
|
442
701
|
async handleOpen() {
|
|
702
|
+
if (this.hasOpenedOnce) {
|
|
703
|
+
this.bufferingGeneration = this.generationTracker.beginGeneration(this.registry.topics());
|
|
704
|
+
if (!this.reconnectBarrier) {
|
|
705
|
+
this.reconnectBarrier = createDeferred();
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
this.hasOpenedOnce = true;
|
|
709
|
+
}
|
|
443
710
|
try {
|
|
444
711
|
await this.subscribeAgentControl();
|
|
445
712
|
} catch (error) {
|
|
@@ -461,6 +728,40 @@ var PhoenixChannelsTransport = class {
|
|
|
461
728
|
channels: getSocketChannelCount(this.socket)
|
|
462
729
|
});
|
|
463
730
|
}
|
|
731
|
+
notifyReconnectObservers(snapshot) {
|
|
732
|
+
const epoch = this.epoch.current;
|
|
733
|
+
const observers = [...this.reconnectObservers];
|
|
734
|
+
void this.observerChain.run(async () => {
|
|
735
|
+
if (this.epoch.isStale(epoch)) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
for (const observer of observers) {
|
|
739
|
+
if (this.epoch.isStale(epoch)) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!this.reconnectObservers.has(observer)) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
await observer(snapshot);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
this.logger.error("Reconnect observer failed", {
|
|
749
|
+
generation: snapshot.generation,
|
|
750
|
+
error
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (!this.epoch.isStale(epoch) && snapshot.generation === this.bufferingGeneration) {
|
|
755
|
+
this.bufferingGeneration = null;
|
|
756
|
+
const events = this.bufferedTopicEvents.splice(0);
|
|
757
|
+
for (const { deliver } of events) {
|
|
758
|
+
deliver();
|
|
759
|
+
}
|
|
760
|
+
this.reconnectBarrier?.resolve();
|
|
761
|
+
this.reconnectBarrier = null;
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
}
|
|
464
765
|
stopReconnectIfNoChannels(options = {}) {
|
|
465
766
|
if (this.stoppingReconnect || getSocketChannelCount(this.socket) !== 0) {
|
|
466
767
|
return;
|
|
@@ -474,18 +775,23 @@ var PhoenixChannelsTransport = class {
|
|
|
474
775
|
if (!this.agentId) {
|
|
475
776
|
return;
|
|
476
777
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
778
|
+
const topic = agentControlTopic(this.agentId);
|
|
779
|
+
await this.join(
|
|
780
|
+
topic,
|
|
781
|
+
{
|
|
782
|
+
supersede: (payload) => {
|
|
783
|
+
const reason = parseSupersedeDisconnectReason(payload);
|
|
784
|
+
if (!reason) {
|
|
785
|
+
this.logger.warn("Invalid agent_control supersede payload", {
|
|
786
|
+
payload
|
|
787
|
+
});
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
this.recordTerminalDisconnect(reason);
|
|
485
791
|
}
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
792
|
+
},
|
|
793
|
+
{ exemptFromBuffering: true }
|
|
794
|
+
);
|
|
489
795
|
}
|
|
490
796
|
recordSocketClose(event) {
|
|
491
797
|
this.connected = false;
|
|
@@ -561,10 +867,6 @@ function unwrapErrorEvent(event) {
|
|
|
561
867
|
function isErrorEvent(event) {
|
|
562
868
|
return typeof event === "object" && event !== null && "error" in event;
|
|
563
869
|
}
|
|
564
|
-
function removeSocketChannel(socket, channel) {
|
|
565
|
-
const candidate = socket;
|
|
566
|
-
candidate.remove?.(channel);
|
|
567
|
-
}
|
|
568
870
|
function getSocketChannelCount(socket) {
|
|
569
871
|
const candidate = socket;
|
|
570
872
|
if (!Array.isArray(candidate.channels)) {
|
|
@@ -578,21 +880,413 @@ import { BandClient } from "@band-ai/rest-client";
|
|
|
578
880
|
import {
|
|
579
881
|
agentContactsTopic,
|
|
580
882
|
agentRoomsTopic,
|
|
581
|
-
|
|
582
|
-
roomParticipantsTopic
|
|
883
|
+
validateEventPayload
|
|
583
884
|
} from "@band-ai/band-sdk-core";
|
|
885
|
+
|
|
886
|
+
// src/platform/SubscriptionManager.ts
|
|
887
|
+
import { SubscriptionTracker } from "@band-ai/band-sdk-core";
|
|
888
|
+
|
|
889
|
+
// src/platform/roomTopics.ts
|
|
890
|
+
import { chatRoomTopic, roomParticipantsTopic } from "@band-ai/band-sdk-core";
|
|
891
|
+
function roomTopics(roomId) {
|
|
892
|
+
return { chat: chatRoomTopic(roomId), participants: roomParticipantsTopic(roomId) };
|
|
893
|
+
}
|
|
894
|
+
function isRejected(result) {
|
|
895
|
+
return result.status === "rejected";
|
|
896
|
+
}
|
|
897
|
+
async function settleRoomLeaves(transport, roomId) {
|
|
898
|
+
const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
|
|
899
|
+
const results = await Promise.allSettled([
|
|
900
|
+
transport.leave(chatTopic),
|
|
901
|
+
transport.leave(participantsTopic)
|
|
902
|
+
]);
|
|
903
|
+
return results.filter(isRejected);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/platform/ReconciliationCoordinator.ts
|
|
907
|
+
var ReconciliationCoordinator = class {
|
|
908
|
+
constructor(tracker, transport, logger, epoch) {
|
|
909
|
+
this.tracker = tracker;
|
|
910
|
+
this.transport = transport;
|
|
911
|
+
this.logger = logger;
|
|
912
|
+
this.epoch = epoch;
|
|
913
|
+
}
|
|
914
|
+
roomsNeedingReconciliation = /* @__PURE__ */ new Set();
|
|
915
|
+
agentTopicsNeedingReconciliation = /* @__PURE__ */ new Set();
|
|
916
|
+
reconcileTail = new Serializer();
|
|
917
|
+
lastReconciledGeneration = 0;
|
|
918
|
+
markRoomNeedsReconciliation(roomId) {
|
|
919
|
+
this.roomsNeedingReconciliation.add(roomId);
|
|
920
|
+
}
|
|
921
|
+
markAgentTopicNeedsReconciliation(topic) {
|
|
922
|
+
this.agentTopicsNeedingReconciliation.add(topic);
|
|
923
|
+
}
|
|
924
|
+
/** Called on session end: every pending sweep is moot against a tracker that has itself just been reset. */
|
|
925
|
+
reset() {
|
|
926
|
+
this.roomsNeedingReconciliation.clear();
|
|
927
|
+
this.agentTopicsNeedingReconciliation.clear();
|
|
928
|
+
this.lastReconciledGeneration = 0;
|
|
929
|
+
}
|
|
930
|
+
/** Serialized: each generation's reconciliation completes before the next begins. */
|
|
931
|
+
reconcileReconnect(snapshot) {
|
|
932
|
+
if (snapshot.generation <= this.lastReconciledGeneration) {
|
|
933
|
+
this.logger.debug("Ignoring stale or duplicate reconnect snapshot", {
|
|
934
|
+
generation: snapshot.generation,
|
|
935
|
+
lastReconciledGeneration: this.lastReconciledGeneration
|
|
936
|
+
});
|
|
937
|
+
return Promise.resolve();
|
|
938
|
+
}
|
|
939
|
+
this.lastReconciledGeneration = snapshot.generation;
|
|
940
|
+
const epoch = this.epoch.current;
|
|
941
|
+
this.tracker.onReconnected();
|
|
942
|
+
const work = {
|
|
943
|
+
snapshot,
|
|
944
|
+
epoch,
|
|
945
|
+
roomCandidates: this.tracker.roomRejoinCandidates(),
|
|
946
|
+
agentTopicCandidates: this.tracker.agentTopicRejoinCandidates()
|
|
947
|
+
};
|
|
948
|
+
return this.reconcileTail.run(() => this.runReconcile(work));
|
|
949
|
+
}
|
|
950
|
+
async runReconcile(work) {
|
|
951
|
+
const { snapshot, epoch, roomCandidates, agentTopicCandidates } = work;
|
|
952
|
+
if (this.epoch.isStale(epoch)) {
|
|
953
|
+
this.logger.debug("Reconnect reconciliation settled after session ended, skipping rejoin evaluation", {
|
|
954
|
+
generation: snapshot.generation
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
for (const [roomId, ticket] of roomCandidates) {
|
|
959
|
+
const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
|
|
960
|
+
if (!snapshot.attemptedTopics.has(chatTopic) || !snapshot.attemptedTopics.has(participantsTopic)) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const present = snapshot.joinedTopics.has(chatTopic) && snapshot.joinedTopics.has(participantsTopic);
|
|
964
|
+
if (present) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (this.tracker.markRoomRejoinFailed(roomId, ticket)) {
|
|
968
|
+
this.markRoomNeedsReconciliation(roomId);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
for (const [topic, ticket] of agentTopicCandidates) {
|
|
972
|
+
if (!snapshot.attemptedTopics.has(topic)) {
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (snapshot.joinedTopics.has(topic)) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (this.tracker.markAgentTopicRejoinFailed(topic, ticket)) {
|
|
979
|
+
this.markAgentTopicNeedsReconciliation(topic);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (this.epoch.isStale(epoch)) {
|
|
983
|
+
this.logger.debug("Reconnect reconciliation settled after session ended, skipping cleanup drain", {
|
|
984
|
+
generation: snapshot.generation
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
await this.drainReconciliation(epoch);
|
|
989
|
+
}
|
|
990
|
+
async drainReconciliation(epoch) {
|
|
991
|
+
const rooms = [...this.roomsNeedingReconciliation];
|
|
992
|
+
const topics = [...this.agentTopicsNeedingReconciliation];
|
|
993
|
+
const [roomLeaveResults, topicLeaveResults] = await Promise.all([
|
|
994
|
+
Promise.allSettled(rooms.map((roomId) => this.leaveRoomTopicsCleanly(roomId))),
|
|
995
|
+
Promise.allSettled(topics.map((topic) => this.transport.leave(topic)))
|
|
996
|
+
]);
|
|
997
|
+
if (this.epoch.isStale(epoch)) {
|
|
998
|
+
this.logger.debug("Reconnect reconciliation cleanup settled after session ended, leaving tracker acknowledgements pending for next reconnect", {
|
|
999
|
+
rooms: rooms.length,
|
|
1000
|
+
topics: topics.length
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
this.acknowledgeCleanup({
|
|
1005
|
+
ids: rooms,
|
|
1006
|
+
results: roomLeaveResults,
|
|
1007
|
+
pending: this.roomsNeedingReconciliation,
|
|
1008
|
+
acknowledge: (roomId) => this.tracker.acknowledgeRoomReconciled(roomId),
|
|
1009
|
+
failureMessage: "Room reconciliation cleanup failed, retrying on next reconnect",
|
|
1010
|
+
logContext: (roomId) => ({ roomId })
|
|
1011
|
+
});
|
|
1012
|
+
this.acknowledgeCleanup({
|
|
1013
|
+
ids: topics,
|
|
1014
|
+
results: topicLeaveResults,
|
|
1015
|
+
pending: this.agentTopicsNeedingReconciliation,
|
|
1016
|
+
acknowledge: (topic) => this.tracker.acknowledgeAgentTopicReconciled(topic),
|
|
1017
|
+
failureMessage: "Agent topic reconciliation cleanup failed, retrying on next reconnect",
|
|
1018
|
+
logContext: (topic) => ({ topic })
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
acknowledgeCleanup(options) {
|
|
1022
|
+
options.ids.forEach((id, index) => {
|
|
1023
|
+
if (options.results[index]?.status !== "fulfilled") {
|
|
1024
|
+
this.logger.warn(options.failureMessage, options.logContext(id));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (options.acknowledge(id)) {
|
|
1028
|
+
options.pending.delete(id);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
async leaveRoomTopicsCleanly(roomId) {
|
|
1033
|
+
const failures = await settleRoomLeaves(this.transport, roomId);
|
|
1034
|
+
if (failures.length > 0) {
|
|
1035
|
+
throw new AggregateError(
|
|
1036
|
+
failures.map((failure) => failure.reason),
|
|
1037
|
+
`Failed to clean up room ${roomId} during reconnect reconciliation`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
// src/platform/SubscriptionManager.ts
|
|
1044
|
+
function roomOperationKey(roomId) {
|
|
1045
|
+
return `room:${roomId}`;
|
|
1046
|
+
}
|
|
1047
|
+
function topicOperationKey(topic) {
|
|
1048
|
+
return `topic:${topic}`;
|
|
1049
|
+
}
|
|
1050
|
+
var SubscriptionManager = class {
|
|
1051
|
+
tracker = new SubscriptionTracker();
|
|
1052
|
+
transport;
|
|
1053
|
+
logger;
|
|
1054
|
+
operations = /* @__PURE__ */ new Map();
|
|
1055
|
+
epoch = new Epoch();
|
|
1056
|
+
reconciliation;
|
|
1057
|
+
constructor(options) {
|
|
1058
|
+
this.transport = options.transport;
|
|
1059
|
+
this.logger = resolveLogger(options.logger);
|
|
1060
|
+
this.reconciliation = new ReconciliationCoordinator(
|
|
1061
|
+
this.tracker,
|
|
1062
|
+
this.transport,
|
|
1063
|
+
this.logger,
|
|
1064
|
+
this.epoch
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
subscribeRoom(roomId, handlers) {
|
|
1068
|
+
return this.runOperation(
|
|
1069
|
+
roomOperationKey(roomId),
|
|
1070
|
+
"up",
|
|
1071
|
+
() => this.claimRoomSubscribe(roomId, handlers)
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
unsubscribeRoom(roomId) {
|
|
1075
|
+
return this.runOperation(
|
|
1076
|
+
roomOperationKey(roomId),
|
|
1077
|
+
"down",
|
|
1078
|
+
() => this.claimRoomUnsubscribe(roomId)
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
subscribeAgentTopic(topic, handlers) {
|
|
1082
|
+
return this.runOperation(
|
|
1083
|
+
topicOperationKey(topic),
|
|
1084
|
+
"up",
|
|
1085
|
+
() => this.claimAgentTopicJoin(topic, handlers)
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
unsubscribeAgentTopic(topic) {
|
|
1089
|
+
return this.runOperation(
|
|
1090
|
+
topicOperationKey(topic),
|
|
1091
|
+
"down",
|
|
1092
|
+
() => this.claimAgentTopicLeave(topic)
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
/** Serialized: each generation's reconciliation completes before the next begins. */
|
|
1096
|
+
reconcileReconnect(snapshot) {
|
|
1097
|
+
return this.reconciliation.reconcileReconnect(snapshot);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Ends this session: every ticket issued so far goes stale, every in-flight
|
|
1101
|
+
* operation's late completion becomes a no-op, and both reconciliation sets
|
|
1102
|
+
* start empty for the next session. The epoch guards host-side async work
|
|
1103
|
+
* and transport effects that can outlive the core session they started in.
|
|
1104
|
+
*/
|
|
1105
|
+
endSession() {
|
|
1106
|
+
this.epoch.bump();
|
|
1107
|
+
this.tracker.endSession();
|
|
1108
|
+
this.operations.clear();
|
|
1109
|
+
this.reconciliation.reset();
|
|
1110
|
+
}
|
|
1111
|
+
leaveOutcome(epoch, succeeded) {
|
|
1112
|
+
return this.epoch.isStale(epoch) ? "unknown" : succeeded ? "left" : "failed";
|
|
1113
|
+
}
|
|
1114
|
+
// ---- generic operation coalescing -------------------------------------
|
|
1115
|
+
runOperation(key, kind, claim) {
|
|
1116
|
+
const existing = this.operations.get(key);
|
|
1117
|
+
if (existing?.kind === kind) {
|
|
1118
|
+
return existing.promise;
|
|
1119
|
+
}
|
|
1120
|
+
const promise = existing ? existing.promise.then(claim, claim) : claim();
|
|
1121
|
+
const operation = { kind, promise };
|
|
1122
|
+
this.operations.set(key, operation);
|
|
1123
|
+
const cleanup = () => {
|
|
1124
|
+
if (this.operations.get(key) === operation) {
|
|
1125
|
+
this.operations.delete(key);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
promise.then(cleanup, cleanup);
|
|
1129
|
+
return promise;
|
|
1130
|
+
}
|
|
1131
|
+
// ---- room subscribe/unsubscribe ----------------------------------------
|
|
1132
|
+
async claimRoomSubscribe(roomId, handlers) {
|
|
1133
|
+
const epoch = this.epoch.current;
|
|
1134
|
+
const ticket = this.tracker.beginRoomSubscribe(roomId);
|
|
1135
|
+
if (ticket === void 0) {
|
|
1136
|
+
return this.settleIdempotentRoomClaim(roomId);
|
|
1137
|
+
}
|
|
1138
|
+
const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
|
|
1139
|
+
try {
|
|
1140
|
+
await this.transport.join(chatTopic, handlers.chat);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (!this.epoch.isStale(epoch)) {
|
|
1143
|
+
this.tracker.recordChatRoomJoinFailed(roomId, ticket);
|
|
1144
|
+
} else {
|
|
1145
|
+
this.logger.debug("Room chat-topic join settled after session ended, ignoring stale ticket", {
|
|
1146
|
+
roomId
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
await this.transport.join(participantsTopic, handlers.participants);
|
|
1153
|
+
} catch (participantError) {
|
|
1154
|
+
if (this.epoch.isStale(epoch)) {
|
|
1155
|
+
this.logger.debug(
|
|
1156
|
+
"Room participants-topic join settled after session ended, ignoring stale ticket",
|
|
1157
|
+
{ roomId }
|
|
1158
|
+
);
|
|
1159
|
+
throw participantError;
|
|
1160
|
+
}
|
|
1161
|
+
let chatRoomLeft = false;
|
|
1162
|
+
let rollbackError;
|
|
1163
|
+
try {
|
|
1164
|
+
await this.transport.leave(chatTopic);
|
|
1165
|
+
chatRoomLeft = true;
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
rollbackError = error;
|
|
1168
|
+
}
|
|
1169
|
+
const result = this.tracker.recordRoomParticipantsJoinFailed(roomId, ticket, chatRoomLeft);
|
|
1170
|
+
if (result === "rollback_failed") {
|
|
1171
|
+
this.reconciliation.markRoomNeedsReconciliation(roomId);
|
|
1172
|
+
throw new AggregateError(
|
|
1173
|
+
[
|
|
1174
|
+
participantError,
|
|
1175
|
+
rollbackError ?? new TransportError(`Failed to roll back chat_room join for room ${roomId}`)
|
|
1176
|
+
],
|
|
1177
|
+
`Failed to subscribe to room ${roomId} and roll back its chat_room join`
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
throw participantError;
|
|
1181
|
+
}
|
|
1182
|
+
if (!this.epoch.isStale(epoch)) {
|
|
1183
|
+
this.tracker.recordBothRoomTopicsJoined(roomId, ticket);
|
|
1184
|
+
} else {
|
|
1185
|
+
this.logger.debug("Room subscribe settled after session ended, discarding stale success", {
|
|
1186
|
+
roomId
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/** Resolves or rejects an idempotent no-op claim (the tracker reported no new ticket to act on). */
|
|
1191
|
+
settleIdempotentClaim(matched, describe) {
|
|
1192
|
+
return matched ? Promise.resolve() : Promise.reject(new RuntimeStateError(describe()));
|
|
1193
|
+
}
|
|
1194
|
+
settleIdempotentRoomClaim(roomId) {
|
|
1195
|
+
const status = this.tracker.roomStatus(roomId);
|
|
1196
|
+
return this.settleIdempotentClaim(
|
|
1197
|
+
status === "subscribed",
|
|
1198
|
+
() => `Room ${roomId} cannot be subscribed while it is "${status}"`
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
async claimRoomUnsubscribe(roomId) {
|
|
1202
|
+
const epoch = this.epoch.current;
|
|
1203
|
+
const ticket = this.tracker.unsubscribeRoom(roomId);
|
|
1204
|
+
if (ticket === void 0) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const failures = await settleRoomLeaves(this.transport, roomId);
|
|
1208
|
+
const outcome = this.leaveOutcome(epoch, failures.length === 0);
|
|
1209
|
+
if (outcome === "unknown") {
|
|
1210
|
+
this.logger.debug("Room unsubscribe settled after session ended, outcome ambiguous", { roomId });
|
|
1211
|
+
}
|
|
1212
|
+
if (this.tracker.markRoomLeaveComplete(roomId, ticket, outcome) && outcome !== "left") {
|
|
1213
|
+
this.reconciliation.markRoomNeedsReconciliation(roomId);
|
|
1214
|
+
}
|
|
1215
|
+
if (failures.length > 0) {
|
|
1216
|
+
throw new AggregateError(
|
|
1217
|
+
failures.map((failure) => failure.reason),
|
|
1218
|
+
`Failed to fully unsubscribe from room ${roomId}`
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
// ---- agent topic join/leave (agent_rooms, agent_contacts) --------------
|
|
1223
|
+
async claimAgentTopicJoin(topic, handlers) {
|
|
1224
|
+
const epoch = this.epoch.current;
|
|
1225
|
+
const ticket = this.tracker.beginAgentTopicJoin(topic);
|
|
1226
|
+
if (ticket === void 0) {
|
|
1227
|
+
return this.settleIdempotentTopicClaim(topic);
|
|
1228
|
+
}
|
|
1229
|
+
let joined = false;
|
|
1230
|
+
let joinError;
|
|
1231
|
+
try {
|
|
1232
|
+
await this.transport.join(topic, handlers);
|
|
1233
|
+
joined = true;
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
joinError = error;
|
|
1236
|
+
}
|
|
1237
|
+
if (this.epoch.isStale(epoch)) {
|
|
1238
|
+
this.tracker.recordAgentTopicJoinAmbiguous(topic, ticket);
|
|
1239
|
+
this.logger.debug("Agent topic join settled after session ended, marking ambiguous for reconciliation", {
|
|
1240
|
+
topic
|
|
1241
|
+
});
|
|
1242
|
+
} else {
|
|
1243
|
+
this.tracker.recordAgentTopicJoin(topic, ticket, joined);
|
|
1244
|
+
}
|
|
1245
|
+
if (!joined) {
|
|
1246
|
+
throw joinError;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
settleIdempotentTopicClaim(topic) {
|
|
1250
|
+
const status = this.tracker.agentTopicStatus(topic);
|
|
1251
|
+
return this.settleIdempotentClaim(
|
|
1252
|
+
status === "joined",
|
|
1253
|
+
() => `Topic ${topic} cannot be joined while it is "${status}"`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
async claimAgentTopicLeave(topic) {
|
|
1257
|
+
const epoch = this.epoch.current;
|
|
1258
|
+
const ticket = this.tracker.leaveAgentTopic(topic);
|
|
1259
|
+
if (ticket === void 0) {
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
let left = true;
|
|
1263
|
+
let leaveError;
|
|
1264
|
+
try {
|
|
1265
|
+
await this.transport.leave(topic);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
left = false;
|
|
1268
|
+
leaveError = error;
|
|
1269
|
+
}
|
|
1270
|
+
const outcome = this.leaveOutcome(epoch, left);
|
|
1271
|
+
if (outcome === "unknown") {
|
|
1272
|
+
this.logger.debug("Agent topic unsubscribe settled after session ended, outcome ambiguous", { topic });
|
|
1273
|
+
}
|
|
1274
|
+
if (this.tracker.markAgentTopicLeaveComplete(topic, ticket, outcome) && outcome !== "left") {
|
|
1275
|
+
this.reconciliation.markAgentTopicNeedsReconciliation(topic);
|
|
1276
|
+
}
|
|
1277
|
+
if (!left) {
|
|
1278
|
+
throw leaveError;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
// src/platform/BandLink.ts
|
|
584
1284
|
var DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket";
|
|
585
1285
|
function deriveDefaultRestUrl(wsUrl) {
|
|
586
1286
|
const parsed = new URL(wsUrl);
|
|
587
1287
|
const protocol = parsed.protocol === "ws:" ? "http:" : "https:";
|
|
588
1288
|
return `${protocol}//${parsed.host}`;
|
|
589
1289
|
}
|
|
590
|
-
function roomTopics(roomId) {
|
|
591
|
-
return {
|
|
592
|
-
chat: chatRoomTopic(roomId),
|
|
593
|
-
participants: roomParticipantsTopic(roomId)
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
1290
|
function toPlatformMessage(roomId, message) {
|
|
597
1291
|
return {
|
|
598
1292
|
id: message.id,
|
|
@@ -606,6 +1300,19 @@ function toPlatformMessage(roomId, message) {
|
|
|
606
1300
|
createdAt: new Date(message.inserted_at)
|
|
607
1301
|
};
|
|
608
1302
|
}
|
|
1303
|
+
function validationErrorDetails(error) {
|
|
1304
|
+
if (typeof error !== "object" || error === null) {
|
|
1305
|
+
return {};
|
|
1306
|
+
}
|
|
1307
|
+
const details = {};
|
|
1308
|
+
if ("issues" in error) {
|
|
1309
|
+
details.issues = error.issues;
|
|
1310
|
+
}
|
|
1311
|
+
if ("traceContext" in error) {
|
|
1312
|
+
details.traceContext = error.traceContext;
|
|
1313
|
+
}
|
|
1314
|
+
return details;
|
|
1315
|
+
}
|
|
609
1316
|
var BandLink = class {
|
|
610
1317
|
agentId;
|
|
611
1318
|
apiKey;
|
|
@@ -615,18 +1322,21 @@ var BandLink = class {
|
|
|
615
1322
|
capabilities;
|
|
616
1323
|
logger;
|
|
617
1324
|
transport;
|
|
618
|
-
|
|
1325
|
+
subscriptionManager;
|
|
619
1326
|
eventQueue = [];
|
|
620
1327
|
waiters = [];
|
|
621
1328
|
connected = false;
|
|
622
1329
|
lastDisconnectReason = null;
|
|
623
1330
|
terminalDisconnectError = null;
|
|
1331
|
+
connectFlight = new SingleFlight();
|
|
1332
|
+
disconnectFlight = new SingleFlight();
|
|
1333
|
+
session = new Session();
|
|
624
1334
|
constructor(options) {
|
|
625
1335
|
this.agentId = options.agentId;
|
|
626
1336
|
this.apiKey = options.apiKey;
|
|
627
1337
|
this.wsUrl = options.wsUrl ?? DEFAULT_WS_URL;
|
|
628
1338
|
this.restUrl = options.restUrl ?? deriveDefaultRestUrl(this.wsUrl);
|
|
629
|
-
this.logger = options.logger
|
|
1339
|
+
this.logger = resolveLogger(options.logger);
|
|
630
1340
|
this.capabilities = {
|
|
631
1341
|
...DEFAULT_AGENT_TOOLS_CAPABILITIES,
|
|
632
1342
|
...options.capabilities
|
|
@@ -648,6 +1358,10 @@ var BandLink = class {
|
|
|
648
1358
|
this.recordDisconnectReason(reason);
|
|
649
1359
|
}
|
|
650
1360
|
});
|
|
1361
|
+
this.subscriptionManager = new SubscriptionManager({
|
|
1362
|
+
transport: this.transport,
|
|
1363
|
+
logger: this.logger
|
|
1364
|
+
});
|
|
651
1365
|
}
|
|
652
1366
|
isConnected() {
|
|
653
1367
|
return this.connected;
|
|
@@ -656,12 +1370,35 @@ var BandLink = class {
|
|
|
656
1370
|
return this.lastDisconnectReason;
|
|
657
1371
|
}
|
|
658
1372
|
async connect() {
|
|
1373
|
+
if (this.disconnectFlight.current) {
|
|
1374
|
+
await this.disconnectFlight.current.catch(() => void 0);
|
|
1375
|
+
}
|
|
659
1376
|
if (this.connected) {
|
|
660
1377
|
return;
|
|
661
1378
|
}
|
|
1379
|
+
await this.connectFlight.run(() => {
|
|
1380
|
+
const epoch = this.session.begin();
|
|
1381
|
+
return this.connectSession(epoch);
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
async connectSession(epoch) {
|
|
1385
|
+
this.session.reconnectObserverTeardown = this.transport.onReconnected?.(async (snapshot) => {
|
|
1386
|
+
await this.subscriptionManager.reconcileReconnect(snapshot);
|
|
1387
|
+
if (this.session.isStale(epoch)) {
|
|
1388
|
+
this.logger.debug(
|
|
1389
|
+
"Reconnect reconciliation settled after session ended, discarding reconnected event"
|
|
1390
|
+
);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
this.queueEvent({ type: "reconnected", roomId: null, payload: {} });
|
|
1394
|
+
}) ?? null;
|
|
662
1395
|
try {
|
|
663
1396
|
await this.transport.connect();
|
|
664
1397
|
} catch (error) {
|
|
1398
|
+
this.session.clearReconnectObserver();
|
|
1399
|
+
this.session.deactivate();
|
|
1400
|
+
await this.transport.disconnect().catch(() => void 0);
|
|
1401
|
+
this.subscriptionManager.endSession();
|
|
665
1402
|
if (error instanceof WebSocketDisconnectError) {
|
|
666
1403
|
if (error.reason.retryable) {
|
|
667
1404
|
this.lastDisconnectReason = error.reason;
|
|
@@ -674,14 +1411,21 @@ var BandLink = class {
|
|
|
674
1411
|
this.connected = true;
|
|
675
1412
|
}
|
|
676
1413
|
async disconnect() {
|
|
677
|
-
|
|
1414
|
+
await this.disconnectFlight.run(() => this.disconnectSession());
|
|
1415
|
+
}
|
|
1416
|
+
async disconnectSession() {
|
|
1417
|
+
await this.connectFlight.current?.catch(() => void 0);
|
|
1418
|
+
if (!this.session.isActive) {
|
|
678
1419
|
return;
|
|
679
1420
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1421
|
+
this.session.deactivate();
|
|
1422
|
+
try {
|
|
1423
|
+
await this.transport.disconnect();
|
|
1424
|
+
} finally {
|
|
1425
|
+
this.connected = false;
|
|
1426
|
+
this.session.clearReconnectObserver();
|
|
1427
|
+
this.subscriptionManager.endSession();
|
|
1428
|
+
}
|
|
685
1429
|
}
|
|
686
1430
|
async runForever(signal) {
|
|
687
1431
|
if (this.terminalDisconnectError) {
|
|
@@ -710,42 +1454,25 @@ var BandLink = class {
|
|
|
710
1454
|
this.eventQueue.push(event);
|
|
711
1455
|
}
|
|
712
1456
|
async subscribeAgentRooms() {
|
|
713
|
-
await this.
|
|
1457
|
+
await this.subscriptionManager.subscribeAgentTopic(agentRoomsTopic(this.agentId), {
|
|
714
1458
|
room_added: (payload) => {
|
|
715
1459
|
const roomId = typeof payload.id === "string" ? payload.id : "";
|
|
716
|
-
this.emit("room_added", payload, roomId);
|
|
717
|
-
},
|
|
718
|
-
room_removed: (payload) => {
|
|
719
|
-
const roomId = typeof payload.id === "string" ? payload.id : "";
|
|
720
|
-
this.emit("room_removed", payload, roomId);
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
async subscribeRoom(roomId) {
|
|
725
|
-
if (this.subscribedRooms.has(roomId)) {
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
await this.joinRoomTopics(roomId);
|
|
729
|
-
this.subscribedRooms.add(roomId);
|
|
730
|
-
}
|
|
731
|
-
async unsubscribeRoom(roomId) {
|
|
732
|
-
if (!this.subscribedRooms.has(roomId)) {
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
const topics = roomTopics(roomId);
|
|
736
|
-
await this.transport.leave(topics.chat);
|
|
737
|
-
await this.transport.leave(topics.participants);
|
|
738
|
-
this.subscribedRooms.delete(roomId);
|
|
739
|
-
}
|
|
740
|
-
async joinRoomTopics(roomId) {
|
|
741
|
-
const topics = roomTopics(roomId);
|
|
742
|
-
await this.transport.join(topics.chat, {
|
|
743
|
-
message_created: (payload) => {
|
|
744
|
-
this.emit("message_created", payload, roomId);
|
|
1460
|
+
this.emit("room_added", payload, roomId);
|
|
1461
|
+
},
|
|
1462
|
+
room_removed: (payload) => {
|
|
1463
|
+
const roomId = typeof payload.id === "string" ? payload.id : "";
|
|
1464
|
+
this.emit("room_removed", payload, roomId);
|
|
745
1465
|
}
|
|
746
1466
|
});
|
|
747
|
-
|
|
748
|
-
|
|
1467
|
+
}
|
|
1468
|
+
async subscribeRoom(roomId) {
|
|
1469
|
+
await this.subscriptionManager.subscribeRoom(roomId, {
|
|
1470
|
+
chat: {
|
|
1471
|
+
message_created: (payload) => {
|
|
1472
|
+
this.emit("message_created", payload, roomId);
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
participants: {
|
|
749
1476
|
participant_added: (payload) => {
|
|
750
1477
|
this.emit("participant_added", payload, roomId);
|
|
751
1478
|
},
|
|
@@ -755,15 +1482,15 @@ var BandLink = class {
|
|
|
755
1482
|
room_deleted: (payload) => {
|
|
756
1483
|
this.emit("room_deleted", payload, roomId);
|
|
757
1484
|
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
async unsubscribeRoom(roomId) {
|
|
1489
|
+
await this.subscriptionManager.unsubscribeRoom(roomId);
|
|
763
1490
|
}
|
|
764
1491
|
async subscribeAgentContacts() {
|
|
765
1492
|
assertCapability(this.capabilities, "contacts", "Contacts streaming");
|
|
766
|
-
await this.
|
|
1493
|
+
await this.subscriptionManager.subscribeAgentTopic(agentContactsTopic(this.agentId), {
|
|
767
1494
|
contact_request_received: (payload) => {
|
|
768
1495
|
this.emit("contact_request_received", payload, null);
|
|
769
1496
|
},
|
|
@@ -779,7 +1506,7 @@ var BandLink = class {
|
|
|
779
1506
|
});
|
|
780
1507
|
}
|
|
781
1508
|
async unsubscribeAgentContacts() {
|
|
782
|
-
await this.
|
|
1509
|
+
await this.subscriptionManager.unsubscribeAgentTopic(agentContactsTopic(this.agentId));
|
|
783
1510
|
}
|
|
784
1511
|
async nextEvent(signal) {
|
|
785
1512
|
if (this.terminalDisconnectError) {
|
|
@@ -935,21 +1662,265 @@ var BandLink = class {
|
|
|
935
1662
|
});
|
|
936
1663
|
}
|
|
937
1664
|
emit(eventType, payload, roomId) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1665
|
+
try {
|
|
1666
|
+
const normalizedPayload = validateEventPayload(eventType, payload);
|
|
1667
|
+
this.queueEvent({
|
|
1668
|
+
type: eventType,
|
|
1669
|
+
roomId,
|
|
1670
|
+
payload: normalizedPayload,
|
|
1671
|
+
raw: payload
|
|
1672
|
+
});
|
|
1673
|
+
} catch (error) {
|
|
941
1674
|
this.logger.warn(`Invalid ${eventType} payload, dropping event`, {
|
|
942
|
-
error
|
|
1675
|
+
...validationErrorDetails(error),
|
|
943
1676
|
roomId
|
|
944
1677
|
});
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
// src/runtime/lifecycle.ts
|
|
1683
|
+
var RUNTIME_LIFECYCLE_TRANSITIONS = {
|
|
1684
|
+
not_started: ["starting"],
|
|
1685
|
+
starting: ["running", "stopping", "stopped", "failed"],
|
|
1686
|
+
running: ["stopping", "failed"],
|
|
1687
|
+
stopping: ["stopped", "failed"],
|
|
1688
|
+
stopped: ["starting"],
|
|
1689
|
+
failed: ["starting", "stopping", "failed"]
|
|
1690
|
+
};
|
|
1691
|
+
function isLegalRuntimeTransition(from, to) {
|
|
1692
|
+
return RUNTIME_LIFECYCLE_TRANSITIONS[from].includes(to);
|
|
1693
|
+
}
|
|
1694
|
+
var EXECUTION_LIFECYCLE_TRANSITIONS = {
|
|
1695
|
+
running: ["stopping", "failed"],
|
|
1696
|
+
stopping: ["stopped", "failed"],
|
|
1697
|
+
stopped: [],
|
|
1698
|
+
failed: []
|
|
1699
|
+
};
|
|
1700
|
+
function isLegalExecutionTransition(from, to) {
|
|
1701
|
+
return EXECUTION_LIFECYCLE_TRANSITIONS[from].includes(to);
|
|
1702
|
+
}
|
|
1703
|
+
function toLifecycleError(value) {
|
|
1704
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
1705
|
+
}
|
|
1706
|
+
var TerminalSignal = class {
|
|
1707
|
+
waiters = /* @__PURE__ */ new Set();
|
|
1708
|
+
outcome = null;
|
|
1709
|
+
/**
|
|
1710
|
+
* Resolves once {@link settle} has been called with `null`, rejects once it has
|
|
1711
|
+
* been called with an error — including for callers that arrive afterwards.
|
|
1712
|
+
*/
|
|
1713
|
+
async wait() {
|
|
1714
|
+
if (this.outcome) {
|
|
1715
|
+
if (this.outcome.error) {
|
|
1716
|
+
throw this.outcome.error;
|
|
1717
|
+
}
|
|
945
1718
|
return;
|
|
946
1719
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1720
|
+
await new Promise((resolve, reject) => {
|
|
1721
|
+
this.waiters.add({ resolve, reject });
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Forget the recorded outcome so a restarted owner can settle again.
|
|
1726
|
+
*
|
|
1727
|
+
* Callers already parked in {@link wait} stay parked — a wait started before a
|
|
1728
|
+
* restart still resolves only when the owner actually stops or fails.
|
|
1729
|
+
*/
|
|
1730
|
+
rearm() {
|
|
1731
|
+
this.outcome = null;
|
|
1732
|
+
}
|
|
1733
|
+
/** First settle wins; later ones are ignored, so a terminal state stays terminal. */
|
|
1734
|
+
settle(error) {
|
|
1735
|
+
if (this.outcome) {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
this.outcome = { error };
|
|
1739
|
+
const pending = [...this.waiters];
|
|
1740
|
+
this.waiters.clear();
|
|
1741
|
+
for (const waiter of pending) {
|
|
1742
|
+
if (error) {
|
|
1743
|
+
waiter.reject(error);
|
|
1744
|
+
} else {
|
|
1745
|
+
waiter.resolve();
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
var SingleFlight2 = class {
|
|
1751
|
+
operation = null;
|
|
1752
|
+
/** The in-flight (or last latched) operation, if any. */
|
|
1753
|
+
get pending() {
|
|
1754
|
+
return this.operation;
|
|
1755
|
+
}
|
|
1756
|
+
/** Forget the latched operation so the next {@link run}/{@link runOrRetry} starts fresh. */
|
|
1757
|
+
reset() {
|
|
1758
|
+
this.operation = null;
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Runs `factory()` once; concurrent and later callers get the same promise.
|
|
1762
|
+
* The result — success or failure — stays latched until {@link reset}.
|
|
1763
|
+
*/
|
|
1764
|
+
run(factory) {
|
|
1765
|
+
if (this.operation) {
|
|
1766
|
+
return this.operation;
|
|
1767
|
+
}
|
|
1768
|
+
const operation = factory();
|
|
1769
|
+
this.operation = operation;
|
|
1770
|
+
return operation;
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Like {@link run}, but unlatches on rejection so the next call retries
|
|
1774
|
+
* instead of replaying the same failure. Only unlatches its own operation —
|
|
1775
|
+
* one already replaced by a {@link reset} or a subsequent call is left alone.
|
|
1776
|
+
*/
|
|
1777
|
+
async runOrRetry(factory) {
|
|
1778
|
+
if (this.operation) {
|
|
1779
|
+
return this.operation;
|
|
1780
|
+
}
|
|
1781
|
+
const operation = factory();
|
|
1782
|
+
this.operation = operation;
|
|
1783
|
+
try {
|
|
1784
|
+
return await operation;
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
if (this.operation === operation) {
|
|
1787
|
+
this.operation = null;
|
|
1788
|
+
}
|
|
1789
|
+
throw error;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
var LifecycleTracker = class {
|
|
1794
|
+
options;
|
|
1795
|
+
current;
|
|
1796
|
+
constructor(initial, options) {
|
|
1797
|
+
this.options = options;
|
|
1798
|
+
Object.freeze(initial);
|
|
1799
|
+
this.current = initial;
|
|
1800
|
+
}
|
|
1801
|
+
/** The current state. Frozen, so callers cannot mutate the instance's lifecycle. */
|
|
1802
|
+
get state() {
|
|
1803
|
+
return this.current;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Whether `state` is still the instance this tracker holds.
|
|
1807
|
+
*
|
|
1808
|
+
* Every accepted {@link transition} installs a *new* frozen object, so an
|
|
1809
|
+
* async operation that captured the state it began under can ask whether the
|
|
1810
|
+
* lifecycle moved on while it was awaiting — "was I superseded?" — without a
|
|
1811
|
+
* second, parallel generation counter living outside the tracker. A status
|
|
1812
|
+
* comparison cannot answer it: a `stop()` followed by a fresh `start()`
|
|
1813
|
+
* returns to `"starting"`, but not to the *same* `"starting"`.
|
|
1814
|
+
*/
|
|
1815
|
+
isCurrent(state) {
|
|
1816
|
+
return this.current === state;
|
|
1817
|
+
}
|
|
1818
|
+
/** Records `next` as the state. Callers pass a fresh object — see {@link isCurrent}. */
|
|
1819
|
+
transition(next, trigger) {
|
|
1820
|
+
const from = this.current.status;
|
|
1821
|
+
if (!this.options.isLegalTransition(from, next.status)) {
|
|
1822
|
+
throw new RuntimeStateError(
|
|
1823
|
+
`${this.options.owner} cannot transition from "${from}" to "${next.status}" (trigger: ${trigger})`
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
Object.freeze(next);
|
|
1827
|
+
this.current = next;
|
|
1828
|
+
this.options.logger?.debug(`${this.options.owner} lifecycle transition`, {
|
|
1829
|
+
...this.options.logContext,
|
|
1830
|
+
from,
|
|
1831
|
+
to: next.status,
|
|
1832
|
+
trigger
|
|
952
1833
|
});
|
|
1834
|
+
this.options.onTransition?.(next);
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Type-guard check against the current status: narrows `this` (and so
|
|
1838
|
+
* `this.state`) to the matching union member on success, e.g.
|
|
1839
|
+
* `if (tracker.is("failed")) { tracker.state.error }`.
|
|
1840
|
+
*
|
|
1841
|
+
* Prefer this over a raw `tracker.state.status === "..."` comparison —
|
|
1842
|
+
* same check, but the payload stays reachable without a separate
|
|
1843
|
+
* destructured snapshot.
|
|
1844
|
+
*/
|
|
1845
|
+
is(status) {
|
|
1846
|
+
return this.current.status === status;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Transition to a `"failed"` state carrying `error`, unless already
|
|
1850
|
+
* terminal (`"stopped"` or `"failed"`), in which case this is a no-op.
|
|
1851
|
+
* Returns whether the transition happened.
|
|
1852
|
+
*
|
|
1853
|
+
* Assumes `S` has a `{ status: "failed", error: Error }` member — true for
|
|
1854
|
+
* both `RuntimeLifecycleState` and `ExecutionLifecycleState`, the only two
|
|
1855
|
+
* states this tracker is used with.
|
|
1856
|
+
*/
|
|
1857
|
+
fail(error, trigger) {
|
|
1858
|
+
if (this.is("stopped") || this.is("failed")) {
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
this.transition({ status: "failed", error: toLifecycleError(error) }, trigger);
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
async function startWithGate(options) {
|
|
1866
|
+
if (options.lifecycle.state.status === "stopping") {
|
|
1867
|
+
throw new RuntimeStateError(`${options.ownerName} cannot start while a stop is in progress`);
|
|
1868
|
+
}
|
|
1869
|
+
if (options.startGate.pending) {
|
|
1870
|
+
return await options.startGate.pending;
|
|
1871
|
+
}
|
|
1872
|
+
options.stopGate.reset();
|
|
1873
|
+
options.stoppedSignal?.rearm();
|
|
1874
|
+
options.lifecycle.transition({ status: "starting" }, "start");
|
|
1875
|
+
await options.startGate.runOrRetry(options.runStart);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/runtime/SyncBoundaryTracker.ts
|
|
1879
|
+
var SyncBoundaryTracker = class {
|
|
1880
|
+
// Every message id a sync scan has ever executed. Entries are never
|
|
1881
|
+
// removed: evicting on a message's own live redelivery was tried and
|
|
1882
|
+
// proved unsafe, because the backend can still return that same id from
|
|
1883
|
+
// `getNextMessage()` on a *later* reconnect's scan before its
|
|
1884
|
+
// mark-as-processed effect has propagated — an eviction keyed on "we saw
|
|
1885
|
+
// it once already" reopens exactly that race, just on a different
|
|
1886
|
+
// trigger. Permanent membership is what makes "already executed"
|
|
1887
|
+
// unconditional, at the cost of one entry per message ever synced via a
|
|
1888
|
+
// backlog scan for the life of this tracker — a real, bounded quantity.
|
|
1889
|
+
executedMessageIds = /* @__PURE__ */ new Set();
|
|
1890
|
+
initial = { messageId: null };
|
|
1891
|
+
// Ordered, oldest first, matching the order scans run in.
|
|
1892
|
+
queue = [this.initial];
|
|
1893
|
+
/** Whether every queued boundary's scan has finished. */
|
|
1894
|
+
get isComplete() {
|
|
1895
|
+
return this.queue.length === 0;
|
|
1896
|
+
}
|
|
1897
|
+
recordExecuted(messageId) {
|
|
1898
|
+
this.executedMessageIds.add(messageId);
|
|
1899
|
+
}
|
|
1900
|
+
isExecuted(messageId) {
|
|
1901
|
+
return this.executedMessageIds.has(messageId);
|
|
1902
|
+
}
|
|
1903
|
+
isSyncPoint(boundary, messageId) {
|
|
1904
|
+
return boundary.messageId !== null && messageId === boundary.messageId;
|
|
1905
|
+
}
|
|
1906
|
+
/** Anchors the oldest not-yet-anchored boundary to a live message's id, if one is open. */
|
|
1907
|
+
anchor(messageId) {
|
|
1908
|
+
const openBoundary = this.queue.find((boundary) => boundary.messageId === null);
|
|
1909
|
+
if (openBoundary) {
|
|
1910
|
+
openBoundary.messageId = messageId;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
/** Starts a new scan boundary for a reconnect and returns it. */
|
|
1914
|
+
beginBoundary() {
|
|
1915
|
+
const boundary = { messageId: null };
|
|
1916
|
+
this.queue.push(boundary);
|
|
1917
|
+
return boundary;
|
|
1918
|
+
}
|
|
1919
|
+
/** Marks `boundary`'s scan finished, dequeuing it only if it's still the oldest. */
|
|
1920
|
+
completeBoundary(boundary) {
|
|
1921
|
+
if (this.queue[0] === boundary) {
|
|
1922
|
+
this.queue.shift();
|
|
1923
|
+
}
|
|
953
1924
|
}
|
|
954
1925
|
};
|
|
955
1926
|
|
|
@@ -983,13 +1954,18 @@ var Execution = class {
|
|
|
983
1954
|
eventQueue = [];
|
|
984
1955
|
waiters = [];
|
|
985
1956
|
idleWaiters = /* @__PURE__ */ new Set();
|
|
986
|
-
|
|
987
|
-
|
|
1957
|
+
stoppedSignal = new TerminalSignal();
|
|
1958
|
+
lifecycle;
|
|
988
1959
|
processTask;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
running = true;
|
|
1960
|
+
stopGate = new SingleFlight2();
|
|
1961
|
+
syncBoundaries = new SyncBoundaryTracker();
|
|
992
1962
|
inFlight = 0;
|
|
1963
|
+
/**
|
|
1964
|
+
* Set once the queue stops accepting new events, ahead of the lifecycle
|
|
1965
|
+
* itself reporting `"stopped"` (see {@link runStop}) — closing the queue
|
|
1966
|
+
* cannot wait on the drain it is closing for.
|
|
1967
|
+
*/
|
|
1968
|
+
closed = false;
|
|
993
1969
|
constructor(options) {
|
|
994
1970
|
this.roomId = options.roomId;
|
|
995
1971
|
this.link = options.link;
|
|
@@ -997,27 +1973,61 @@ var Execution = class {
|
|
|
997
1973
|
this.retryTracker = this.context.getRetryTracker();
|
|
998
1974
|
this.onExecute = options.onExecute;
|
|
999
1975
|
this.onFailure = options.onFailure;
|
|
1000
|
-
this.logger = options.logger
|
|
1001
|
-
this.
|
|
1976
|
+
this.logger = resolveLogger(options.logger);
|
|
1977
|
+
this.lifecycle = new LifecycleTracker({ status: "running" }, {
|
|
1978
|
+
owner: "Execution",
|
|
1979
|
+
logContext: { roomId: this.roomId },
|
|
1980
|
+
logger: this.logger,
|
|
1981
|
+
isLegalTransition: isLegalExecutionTransition,
|
|
1982
|
+
onTransition: (state) => {
|
|
1983
|
+
if (state.status === "stopped") {
|
|
1984
|
+
this.stoppedSignal.settle(null);
|
|
1985
|
+
} else if (state.status === "failed") {
|
|
1986
|
+
this.stoppedSignal.settle(state.error);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
this.processTask = this.runProcessLoop();
|
|
1991
|
+
void this.processTask.catch(() => void 0);
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Whether this `Execution` is still alive, and if not, why it ended.
|
|
1995
|
+
*
|
|
1996
|
+
* This is the *lifecycle* axis. For "is this turn's handler currently
|
|
1997
|
+
* executing" read `ExecutionContext.state` instead, which reports
|
|
1998
|
+
* `"starting" | "idle" | "processing"` for the room's context.
|
|
1999
|
+
*
|
|
2000
|
+
* @see ExecutionContext.state
|
|
2001
|
+
*/
|
|
2002
|
+
get state() {
|
|
2003
|
+
return this.lifecycle.state;
|
|
1002
2004
|
}
|
|
1003
|
-
enqueue(event) {
|
|
1004
|
-
if (
|
|
1005
|
-
|
|
2005
|
+
async enqueue(event) {
|
|
2006
|
+
if (this.lifecycle.is("stopped") || this.lifecycle.is("failed") || this.closed) {
|
|
2007
|
+
throw new RuntimeStateError(
|
|
2008
|
+
`Execution for room ${this.roomId} has already ended or is stopping (status: ${this.lifecycle.state.status}); enqueue() is a no-op after stop()`
|
|
2009
|
+
);
|
|
1006
2010
|
}
|
|
2011
|
+
let syncBoundary = null;
|
|
2012
|
+
if (event.type === "reconnected") {
|
|
2013
|
+
syncBoundary = this.syncBoundaries.beginBoundary();
|
|
2014
|
+
} else if (event.type === "message_created") {
|
|
2015
|
+
this.syncBoundaries.anchor(event.payload.id);
|
|
2016
|
+
}
|
|
2017
|
+
const queued = { event, syncBoundary };
|
|
1007
2018
|
const waiter = this.waiters.shift();
|
|
1008
2019
|
if (waiter) {
|
|
1009
|
-
waiter(
|
|
2020
|
+
waiter(queued);
|
|
1010
2021
|
} else {
|
|
1011
|
-
this.eventQueue.push(
|
|
2022
|
+
this.eventQueue.push(queued);
|
|
1012
2023
|
}
|
|
1013
|
-
return Promise.resolve();
|
|
1014
2024
|
}
|
|
1015
2025
|
async bootstrapMessage(message) {
|
|
1016
|
-
this.
|
|
2026
|
+
this.syncBoundaries.recordExecuted(message.id);
|
|
1017
2027
|
await this.executeSyncMessage(toMessageEvent(message), message.id);
|
|
1018
2028
|
}
|
|
1019
2029
|
isIdle() {
|
|
1020
|
-
return this.
|
|
2030
|
+
return this.syncBoundaries.isComplete && this.inFlight === 0 && this.eventQueue.length === 0;
|
|
1021
2031
|
}
|
|
1022
2032
|
async waitForIdle(timeoutMs) {
|
|
1023
2033
|
if (this.isIdle()) {
|
|
@@ -1052,27 +2062,58 @@ var Execution = class {
|
|
|
1052
2062
|
});
|
|
1053
2063
|
}
|
|
1054
2064
|
async stop(timeoutMs) {
|
|
2065
|
+
return await this.stopGate.run(() => this.runStop(timeoutMs));
|
|
2066
|
+
}
|
|
2067
|
+
async runStop(timeoutMs) {
|
|
2068
|
+
if (this.lifecycle.is("failed")) {
|
|
2069
|
+
throw this.lifecycle.state.error;
|
|
2070
|
+
}
|
|
2071
|
+
if (this.lifecycle.is("stopped")) {
|
|
2072
|
+
return this.lifecycle.state.graceful;
|
|
2073
|
+
}
|
|
2074
|
+
this.lifecycle.transition({ status: "stopping" }, "stop");
|
|
1055
2075
|
const graceful = await this.waitForIdle(timeoutMs);
|
|
1056
|
-
this.
|
|
2076
|
+
if (this.lifecycle.is("failed")) {
|
|
2077
|
+
throw this.lifecycle.state.error;
|
|
2078
|
+
}
|
|
2079
|
+
this.closed = true;
|
|
1057
2080
|
this.resolveEventWaiters(null);
|
|
1058
2081
|
if (graceful || timeoutMs === void 0) {
|
|
1059
2082
|
await this.processTask;
|
|
1060
2083
|
}
|
|
2084
|
+
this.lifecycle.transition({ status: "stopped", graceful }, graceful ? "stopped" : "stopped-forced");
|
|
1061
2085
|
return graceful;
|
|
1062
2086
|
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Resolve once this `Execution` has reached a terminal state — including a
|
|
2089
|
+
* forced, non-graceful stop that detached the process loop — or reject with
|
|
2090
|
+
* the error that ended it.
|
|
2091
|
+
*/
|
|
1063
2092
|
async waitUntilStopped() {
|
|
1064
|
-
await this.
|
|
2093
|
+
await this.stoppedSignal.wait();
|
|
2094
|
+
}
|
|
2095
|
+
async runProcessLoop() {
|
|
2096
|
+
try {
|
|
2097
|
+
await this.processLoop();
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
this.markFailed(error, "process-loop-failed");
|
|
2100
|
+
throw error;
|
|
2101
|
+
}
|
|
1065
2102
|
}
|
|
1066
2103
|
async processLoop() {
|
|
1067
2104
|
await this.recoverStaleProcessingMessages();
|
|
1068
|
-
await this.synchronizeWithNext();
|
|
1069
|
-
while (this.
|
|
1070
|
-
const
|
|
1071
|
-
if (!
|
|
2105
|
+
await this.synchronizeWithNext(this.syncBoundaries.initial);
|
|
2106
|
+
while (this.isActive()) {
|
|
2107
|
+
const queued = await this.nextQueuedEvent();
|
|
2108
|
+
if (!queued) {
|
|
1072
2109
|
return;
|
|
1073
2110
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
2111
|
+
const { event } = queued;
|
|
2112
|
+
if (event.type === "reconnected") {
|
|
2113
|
+
await this.synchronizeWithNext(queued.syncBoundary ?? { messageId: null });
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
if (event.type === "message_created" && this.syncBoundaries.isExecuted(event.payload.id)) {
|
|
1076
2117
|
this.notifyIfIdle();
|
|
1077
2118
|
continue;
|
|
1078
2119
|
}
|
|
@@ -1097,7 +2138,7 @@ var Execution = class {
|
|
|
1097
2138
|
count: staleMessages.length
|
|
1098
2139
|
});
|
|
1099
2140
|
for (const message of staleMessages) {
|
|
1100
|
-
if (!this.
|
|
2141
|
+
if (!this.isActive()) {
|
|
1101
2142
|
break;
|
|
1102
2143
|
}
|
|
1103
2144
|
if (this.retryTracker.isPermanentlyFailed(message.id)) {
|
|
@@ -1108,20 +2149,17 @@ var Execution = class {
|
|
|
1108
2149
|
continue;
|
|
1109
2150
|
}
|
|
1110
2151
|
await this.executeSyncMessage(toMessageEvent(message), message.id);
|
|
1111
|
-
this.
|
|
2152
|
+
this.syncBoundaries.recordExecuted(message.id);
|
|
1112
2153
|
}
|
|
1113
2154
|
}
|
|
1114
|
-
async synchronizeWithNext() {
|
|
1115
|
-
while (this.
|
|
2155
|
+
async synchronizeWithNext(boundary) {
|
|
2156
|
+
while (this.isActive()) {
|
|
1116
2157
|
const nextMessage = await this.link.getNextMessage(this.roomId);
|
|
1117
2158
|
if (!nextMessage) {
|
|
1118
2159
|
break;
|
|
1119
2160
|
}
|
|
1120
|
-
if (this.
|
|
1121
|
-
|
|
1122
|
-
if (isSyncPoint2) {
|
|
1123
|
-
this.drainedWsMessageIds.add(nextMessage.id);
|
|
1124
|
-
this.firstWsMessageId = null;
|
|
2161
|
+
if (this.syncBoundaries.isExecuted(nextMessage.id)) {
|
|
2162
|
+
if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
|
|
1125
2163
|
break;
|
|
1126
2164
|
}
|
|
1127
2165
|
continue;
|
|
@@ -1132,25 +2170,19 @@ var Execution = class {
|
|
|
1132
2170
|
messageId: nextMessage.id
|
|
1133
2171
|
});
|
|
1134
2172
|
await this.markMessageFailed(nextMessage.id, "Message permanently failed after max retries");
|
|
1135
|
-
|
|
1136
|
-
if (
|
|
1137
|
-
this.drainedWsMessageIds.add(nextMessage.id);
|
|
1138
|
-
this.firstWsMessageId = null;
|
|
2173
|
+
this.syncBoundaries.recordExecuted(nextMessage.id);
|
|
2174
|
+
if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
|
|
1139
2175
|
break;
|
|
1140
2176
|
}
|
|
1141
2177
|
continue;
|
|
1142
2178
|
}
|
|
1143
|
-
const isSyncPoint = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
|
|
1144
2179
|
await this.executeSyncMessage(toMessageEvent(nextMessage), nextMessage.id);
|
|
1145
|
-
this.
|
|
1146
|
-
if (isSyncPoint) {
|
|
1147
|
-
this.drainedWsMessageIds.add(nextMessage.id);
|
|
1148
|
-
this.firstWsMessageId = null;
|
|
2180
|
+
this.syncBoundaries.recordExecuted(nextMessage.id);
|
|
2181
|
+
if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
|
|
1149
2182
|
break;
|
|
1150
2183
|
}
|
|
1151
2184
|
}
|
|
1152
|
-
this.
|
|
1153
|
-
this.syncComplete = true;
|
|
2185
|
+
this.syncBoundaries.completeBoundary(boundary);
|
|
1154
2186
|
this.notifyIfIdle();
|
|
1155
2187
|
}
|
|
1156
2188
|
async executeSyncMessage(event, messageId) {
|
|
@@ -1187,6 +2219,14 @@ var Execution = class {
|
|
|
1187
2219
|
try {
|
|
1188
2220
|
await this.onExecute(this.context, event);
|
|
1189
2221
|
} catch (error) {
|
|
2222
|
+
if (error instanceof RecoverableTurnError) {
|
|
2223
|
+
this.logger.warn("Turn failed without stopping the room", {
|
|
2224
|
+
roomId: this.roomId,
|
|
2225
|
+
eventType: event.type,
|
|
2226
|
+
error
|
|
2227
|
+
});
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
1190
2230
|
if (this.onFailure) {
|
|
1191
2231
|
await this.onFailure(error, event);
|
|
1192
2232
|
} else {
|
|
@@ -1196,7 +2236,7 @@ var Execution = class {
|
|
|
1196
2236
|
error
|
|
1197
2237
|
});
|
|
1198
2238
|
}
|
|
1199
|
-
this.
|
|
2239
|
+
this.markFailed(error, "execution-failed");
|
|
1200
2240
|
this.eventQueue.splice(0, this.eventQueue.length);
|
|
1201
2241
|
this.resolveEventWaiters(null);
|
|
1202
2242
|
throw error;
|
|
@@ -1211,13 +2251,21 @@ var Execution = class {
|
|
|
1211
2251
|
if (queued) {
|
|
1212
2252
|
return queued;
|
|
1213
2253
|
}
|
|
1214
|
-
if (!this.
|
|
2254
|
+
if (this.closed || !this.isActive()) {
|
|
1215
2255
|
return null;
|
|
1216
2256
|
}
|
|
1217
2257
|
return new Promise((resolve) => {
|
|
1218
2258
|
this.waiters.push(resolve);
|
|
1219
2259
|
});
|
|
1220
2260
|
}
|
|
2261
|
+
/** True while the process loop should keep draining (`running` or mid-`stop()`). */
|
|
2262
|
+
isActive() {
|
|
2263
|
+
const status = this.lifecycle.state.status;
|
|
2264
|
+
return status === "running" || status === "stopping";
|
|
2265
|
+
}
|
|
2266
|
+
markFailed(error, trigger) {
|
|
2267
|
+
this.lifecycle.fail(error, trigger);
|
|
2268
|
+
}
|
|
1221
2269
|
notifyIfIdle() {
|
|
1222
2270
|
if (!this.isIdle()) {
|
|
1223
2271
|
return;
|
|
@@ -1248,34 +2296,29 @@ var Execution = class {
|
|
|
1248
2296
|
|
|
1249
2297
|
// src/runtime/tools/AgentTools.ts
|
|
1250
2298
|
import { ParticipantRoster } from "@band-ai/band-sdk-core";
|
|
1251
|
-
var
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
"storeMemory",
|
|
1275
|
-
"getMemory",
|
|
1276
|
-
"supersedeMemory",
|
|
1277
|
-
"archiveMemory"
|
|
1278
|
-
]
|
|
2299
|
+
var ADAPTER_TOOL_METHODS = {
|
|
2300
|
+
sendMessage: null,
|
|
2301
|
+
sendEvent: null,
|
|
2302
|
+
sendFailure: null,
|
|
2303
|
+
addParticipant: null,
|
|
2304
|
+
removeParticipant: null,
|
|
2305
|
+
getParticipants: null,
|
|
2306
|
+
createChatroom: null,
|
|
2307
|
+
getToolSchemas: null,
|
|
2308
|
+
getAnthropicToolSchemas: null,
|
|
2309
|
+
getOpenAIToolSchemas: null,
|
|
2310
|
+
executeToolCall: null,
|
|
2311
|
+
lookupPeers: "peers",
|
|
2312
|
+
listContacts: "contacts",
|
|
2313
|
+
addContact: "contacts",
|
|
2314
|
+
removeContact: "contacts",
|
|
2315
|
+
listContactRequests: "contacts",
|
|
2316
|
+
respondContactRequest: "contacts",
|
|
2317
|
+
listMemories: "memory",
|
|
2318
|
+
storeMemory: "memory",
|
|
2319
|
+
getMemory: "memory",
|
|
2320
|
+
supersedeMemory: "memory",
|
|
2321
|
+
archiveMemory: "memory"
|
|
1279
2322
|
};
|
|
1280
2323
|
var CONTACT_REQUEST_ACTIONS = /* @__PURE__ */ new Set([
|
|
1281
2324
|
"approve",
|
|
@@ -1294,7 +2337,7 @@ var AgentTools = class {
|
|
|
1294
2337
|
this.roomId = options.roomId;
|
|
1295
2338
|
this.rest = options.rest;
|
|
1296
2339
|
this.roster = options.roster ?? new ParticipantRoster();
|
|
1297
|
-
this.logger = options.logger
|
|
2340
|
+
this.logger = resolveLogger(options.logger);
|
|
1298
2341
|
this.capabilities = {
|
|
1299
2342
|
...DEFAULT_AGENT_TOOLS_CAPABILITIES,
|
|
1300
2343
|
...options.capabilities
|
|
@@ -1334,14 +2377,14 @@ var AgentTools = class {
|
|
|
1334
2377
|
}
|
|
1335
2378
|
);
|
|
1336
2379
|
} catch (error) {
|
|
1337
|
-
|
|
1338
|
-
this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
|
|
1339
|
-
} catch {
|
|
1340
|
-
}
|
|
2380
|
+
this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
|
|
1341
2381
|
const message = error instanceof Error ? error.message : String(error);
|
|
1342
2382
|
return { ok: false, status: "failed", message };
|
|
1343
2383
|
}
|
|
1344
2384
|
}
|
|
2385
|
+
async sendFailure(failure) {
|
|
2386
|
+
return sendFailureViaEvent(this.sendEvent.bind(this), failure);
|
|
2387
|
+
}
|
|
1345
2388
|
async createChatroom(taskId) {
|
|
1346
2389
|
const room = await this.rest.createChat(taskId, DEFAULT_REQUEST_OPTIONS);
|
|
1347
2390
|
return room.id;
|
|
@@ -1465,6 +2508,7 @@ var AgentTools = class {
|
|
|
1465
2508
|
legacyMessage: `Invalid arguments for ${toolName}: ${error.message}`
|
|
1466
2509
|
});
|
|
1467
2510
|
}
|
|
2511
|
+
this.logger.error("unexpected tool execution error", { toolName, error });
|
|
1468
2512
|
const message = error instanceof Error ? error.message : String(error);
|
|
1469
2513
|
return createToolExecutorError({
|
|
1470
2514
|
errorType: "ToolExecutionError",
|
|
@@ -1751,16 +2795,11 @@ var AgentTools = class {
|
|
|
1751
2795
|
const tools = {
|
|
1752
2796
|
capabilities: this.capabilities
|
|
1753
2797
|
};
|
|
1754
|
-
for (const methodName of
|
|
1755
|
-
|
|
1756
|
-
}
|
|
1757
|
-
for (const [capabilityKey, methodNames] of Object.entries(OPTIONAL_ADAPTER_TOOL_METHODS)) {
|
|
1758
|
-
if (!this.capabilities[capabilityKey]) {
|
|
2798
|
+
for (const [methodName, capability] of Object.entries(ADAPTER_TOOL_METHODS)) {
|
|
2799
|
+
if (capability !== null && !this.capabilities[capability]) {
|
|
1759
2800
|
continue;
|
|
1760
2801
|
}
|
|
1761
|
-
|
|
1762
|
-
tools[methodName] = this.bindAdapterToolMethod(methodName);
|
|
1763
|
-
}
|
|
2802
|
+
tools[methodName] = this.bindAdapterToolMethod(methodName);
|
|
1764
2803
|
}
|
|
1765
2804
|
return Object.freeze(tools);
|
|
1766
2805
|
}
|
|
@@ -1908,9 +2947,9 @@ var AgentTools = class {
|
|
|
1908
2947
|
if (!segment) {
|
|
1909
2948
|
throw new ValidationError(`segment must be one of: ${expectedList(MEMORY_SEGMENTS)}`);
|
|
1910
2949
|
}
|
|
1911
|
-
if (scope ===
|
|
2950
|
+
if (scope === MEMORY_STORE_SCOPE.subject && !subjectId) {
|
|
1912
2951
|
throw new ValidationError(
|
|
1913
|
-
|
|
2952
|
+
`scope="${MEMORY_STORE_SCOPE.subject}" requires a subject_id (the UUID of the person or agent the memory is about). If you do not have a concrete subject UUID, retry with scope="${MEMORY_STORE_SCOPE.agent}" and omit subject_id. Do not invent a UUID.`
|
|
1914
2953
|
);
|
|
1915
2954
|
}
|
|
1916
2955
|
return {
|
|
@@ -2088,22 +3127,6 @@ function validateToolArgs(toolName, args) {
|
|
|
2088
3127
|
);
|
|
2089
3128
|
}
|
|
2090
3129
|
}
|
|
2091
|
-
if (toolName === "band_store_memory") {
|
|
2092
|
-
if (typeof args.system === "string" && !isMemorySystem(args.system)) {
|
|
2093
|
-
errors.push(`system: Invalid value '${args.system}'. Expected one of: ${expectedList(MEMORY_SYSTEMS)}`);
|
|
2094
|
-
}
|
|
2095
|
-
if (typeof args.type === "string" && !isMemoryType(args.type)) {
|
|
2096
|
-
errors.push(`type: Invalid value '${args.type}'. Expected one of: ${expectedList(MEMORY_TYPES)}`);
|
|
2097
|
-
}
|
|
2098
|
-
if (typeof args.system === "string" && isMemorySystem(args.system) && typeof args.type === "string" && isMemoryType(args.type) && !isMemoryTypeForSystem(args.system, args.type)) {
|
|
2099
|
-
errors.push(
|
|
2100
|
-
`type: Invalid value '${args.type}' for system '${args.system}'. Expected one of: ${expectedMemoryTypesForSystem(args.system)}`
|
|
2101
|
-
);
|
|
2102
|
-
}
|
|
2103
|
-
if (typeof args.segment === "string" && !isMemorySegment(args.segment)) {
|
|
2104
|
-
errors.push(`segment: Invalid value '${args.segment}'. Expected one of: ${expectedList(MEMORY_SEGMENTS)}`);
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
3130
|
if (errors.length > 0) {
|
|
2108
3131
|
const message = `Invalid arguments for ${toolName}: ${errors.join("; ")}`;
|
|
2109
3132
|
return createToolExecutorError({
|
|
@@ -2154,10 +3177,21 @@ var ExecutionContext = class {
|
|
|
2154
3177
|
rest: this.link.rest,
|
|
2155
3178
|
roster: this.roster,
|
|
2156
3179
|
capabilities: this.link.capabilities,
|
|
2157
|
-
logger: options.logger
|
|
3180
|
+
logger: resolveLogger(options.logger)
|
|
2158
3181
|
});
|
|
2159
3182
|
this.adapterTools = this.tools.getAdapterTools();
|
|
2160
3183
|
}
|
|
3184
|
+
/**
|
|
3185
|
+
* What this room's turn handler is currently doing.
|
|
3186
|
+
*
|
|
3187
|
+
* This is the *per-turn activity* axis, not a lifecycle: it flips to
|
|
3188
|
+
* `"processing"` while the adapter handles an event and back to `"idle"`
|
|
3189
|
+
* afterwards. To ask whether the room's `Execution` is still alive at all,
|
|
3190
|
+
* read `Execution.state` instead, which reports
|
|
3191
|
+
* `"running" | "stopping" | "stopped" | "failed"`.
|
|
3192
|
+
*
|
|
3193
|
+
* @see Execution.state
|
|
3194
|
+
*/
|
|
2161
3195
|
get state() {
|
|
2162
3196
|
return this._state;
|
|
2163
3197
|
}
|
|
@@ -2406,18 +3440,26 @@ var ExecutionContext = class {
|
|
|
2406
3440
|
import { RoomRoster } from "@band-ai/band-sdk-core";
|
|
2407
3441
|
|
|
2408
3442
|
// src/runtime/rooms/subscriptions.ts
|
|
3443
|
+
async function listExistingRooms(options) {
|
|
3444
|
+
const rooms = await options.link.listAllChats(void 0, options.requestOptions);
|
|
3445
|
+
const accepted = /* @__PURE__ */ new Map();
|
|
3446
|
+
for (const room of rooms) {
|
|
3447
|
+
const roomId = typeof room.id === "string" ? room.id : null;
|
|
3448
|
+
if (!roomId) {
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
if (options.roomFilter && !options.roomFilter(room)) {
|
|
3452
|
+
continue;
|
|
3453
|
+
}
|
|
3454
|
+
accepted.set(roomId, room);
|
|
3455
|
+
}
|
|
3456
|
+
return accepted;
|
|
3457
|
+
}
|
|
2409
3458
|
async function hydrateExistingRooms(options) {
|
|
2410
3459
|
try {
|
|
2411
|
-
const rooms = await
|
|
2412
|
-
for (const
|
|
2413
|
-
|
|
2414
|
-
if (!roomId) {
|
|
2415
|
-
continue;
|
|
2416
|
-
}
|
|
2417
|
-
if (options.roomFilter && !options.roomFilter(room)) {
|
|
2418
|
-
continue;
|
|
2419
|
-
}
|
|
2420
|
-
await options.onRoom(roomId, room);
|
|
3460
|
+
const rooms = await listExistingRooms(options);
|
|
3461
|
+
for (const [roomId, payload] of rooms) {
|
|
3462
|
+
await options.onRoom(roomId, payload);
|
|
2421
3463
|
}
|
|
2422
3464
|
} catch (error) {
|
|
2423
3465
|
if (error instanceof UnsupportedFeatureError) {
|
|
@@ -2445,7 +3487,7 @@ var RoomPresence = class {
|
|
|
2445
3487
|
eventController = null;
|
|
2446
3488
|
eventTask = null;
|
|
2447
3489
|
contactsSubscribed = false;
|
|
2448
|
-
lifecycle =
|
|
3490
|
+
lifecycle = new Serializer();
|
|
2449
3491
|
admissionInFlight = /* @__PURE__ */ new Map();
|
|
2450
3492
|
// Read once by `admitRoomOrThrow` right after a failed admission; a
|
|
2451
3493
|
// subsequent successful subscribe clears it so a caller never attributes
|
|
@@ -2455,13 +3497,13 @@ var RoomPresence = class {
|
|
|
2455
3497
|
this.link = options.link;
|
|
2456
3498
|
this.roomFilter = options.roomFilter;
|
|
2457
3499
|
this.autoSubscribeExistingRooms = options.autoSubscribeExistingRooms ?? true;
|
|
2458
|
-
this.logger = options.logger
|
|
3500
|
+
this.logger = resolveLogger(options.logger);
|
|
2459
3501
|
}
|
|
2460
3502
|
async start() {
|
|
2461
|
-
return this.
|
|
3503
|
+
return this.lifecycle.run(() => this.startBody());
|
|
2462
3504
|
}
|
|
2463
3505
|
async stop() {
|
|
2464
|
-
return this.
|
|
3506
|
+
return this.lifecycle.run(() => this.stopBody());
|
|
2465
3507
|
}
|
|
2466
3508
|
async [Symbol.asyncDispose]() {
|
|
2467
3509
|
await this.stop();
|
|
@@ -2526,14 +3568,6 @@ var RoomPresence = class {
|
|
|
2526
3568
|
}
|
|
2527
3569
|
return roomIsAdmitted;
|
|
2528
3570
|
}
|
|
2529
|
-
async serialize(body) {
|
|
2530
|
-
const run = this.lifecycle.then(body, body);
|
|
2531
|
-
this.lifecycle = run.then(
|
|
2532
|
-
() => void 0,
|
|
2533
|
-
() => void 0
|
|
2534
|
-
);
|
|
2535
|
-
return run;
|
|
2536
|
-
}
|
|
2537
3571
|
async startBody() {
|
|
2538
3572
|
if (this.eventTask) {
|
|
2539
3573
|
throw new RuntimeStateError("RoomPresence is already started");
|
|
@@ -2541,14 +3575,8 @@ var RoomPresence = class {
|
|
|
2541
3575
|
if (!this.link.isConnected()) {
|
|
2542
3576
|
await this.link.connect();
|
|
2543
3577
|
}
|
|
2544
|
-
const contactsReady = this.subscribeContacts();
|
|
2545
|
-
|
|
2546
|
-
await this.link.subscribeAgentRooms();
|
|
2547
|
-
} catch (error) {
|
|
2548
|
-
this.logger.warn("RoomPresence failed to subscribe agent_rooms channel, continuing without it", {
|
|
2549
|
-
error
|
|
2550
|
-
});
|
|
2551
|
-
}
|
|
3578
|
+
const contactsReady = this.subscribeContacts("start");
|
|
3579
|
+
await this.subscribeAgentRoomsChannel("start");
|
|
2552
3580
|
if (this.autoSubscribeExistingRooms) {
|
|
2553
3581
|
await this.subscribeExistingRooms();
|
|
2554
3582
|
}
|
|
@@ -2556,7 +3584,17 @@ var RoomPresence = class {
|
|
|
2556
3584
|
this.eventController = new AbortController();
|
|
2557
3585
|
this.eventTask = this.consumeEvents(this.eventController.signal);
|
|
2558
3586
|
}
|
|
2559
|
-
|
|
3587
|
+
contextualWarnMessage(channel, context) {
|
|
3588
|
+
return context === "reconnect" ? `RoomPresence failed to resubscribe ${channel} channel after reconnect` : `RoomPresence failed to subscribe ${channel} channel, continuing without it`;
|
|
3589
|
+
}
|
|
3590
|
+
async subscribeAgentRoomsChannel(context) {
|
|
3591
|
+
try {
|
|
3592
|
+
await this.link.subscribeAgentRooms();
|
|
3593
|
+
} catch (error) {
|
|
3594
|
+
this.logger.warn(this.contextualWarnMessage("agent_rooms", context), { error });
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
async subscribeContacts(context) {
|
|
2560
3598
|
if (!this.link.capabilities.contacts) {
|
|
2561
3599
|
return;
|
|
2562
3600
|
}
|
|
@@ -2564,9 +3602,7 @@ var RoomPresence = class {
|
|
|
2564
3602
|
await this.link.subscribeAgentContacts();
|
|
2565
3603
|
this.contactsSubscribed = true;
|
|
2566
3604
|
} catch (error) {
|
|
2567
|
-
this.logger.warn("
|
|
2568
|
-
error
|
|
2569
|
-
});
|
|
3605
|
+
this.logger.warn(this.contextualWarnMessage("agent_contacts", context), { error });
|
|
2570
3606
|
}
|
|
2571
3607
|
}
|
|
2572
3608
|
async stopBody() {
|
|
@@ -2620,6 +3656,9 @@ var RoomPresence = class {
|
|
|
2620
3656
|
await this.onRoomEvent?.(event.roomId, event);
|
|
2621
3657
|
}
|
|
2622
3658
|
break;
|
|
3659
|
+
case "reconnected":
|
|
3660
|
+
await this.handleReconnected(event);
|
|
3661
|
+
break;
|
|
2623
3662
|
default:
|
|
2624
3663
|
assertNever(event);
|
|
2625
3664
|
}
|
|
@@ -2638,14 +3677,74 @@ var RoomPresence = class {
|
|
|
2638
3677
|
if (!roomId) {
|
|
2639
3678
|
return;
|
|
2640
3679
|
}
|
|
2641
|
-
await this.
|
|
2642
|
-
this.lastSubscribeError.delete(roomId);
|
|
3680
|
+
await this.leaveRoomTracking(roomId);
|
|
2643
3681
|
if (!this.roster.recordRoomRemoved(roomId)) {
|
|
2644
3682
|
this.logger.debug("RoomPresence ignoring removal for untracked room", { roomId });
|
|
2645
3683
|
return;
|
|
2646
3684
|
}
|
|
2647
3685
|
await this.onRoomLeft?.(roomId);
|
|
2648
3686
|
}
|
|
3687
|
+
/** Unsubscribes the transport topic and clears any remembered subscribe failure for `roomId`. */
|
|
3688
|
+
async leaveRoomTracking(roomId) {
|
|
3689
|
+
await this.unsubscribeRoom(roomId);
|
|
3690
|
+
this.lastSubscribeError.delete(roomId);
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* A REST fetch failure here must not blank the roster — it forwards the
|
|
3694
|
+
* reconnect to every currently tracked room regardless, so each room's
|
|
3695
|
+
* `Execution` can still re-run its `/next` synchronization even when
|
|
3696
|
+
* membership reconciliation itself has to wait for the next reconnect.
|
|
3697
|
+
*/
|
|
3698
|
+
async handleReconnected(event) {
|
|
3699
|
+
const [, , accepted] = await Promise.all([
|
|
3700
|
+
this.subscribeAgentRoomsChannel("reconnect"),
|
|
3701
|
+
this.subscribeContacts("reconnect"),
|
|
3702
|
+
listExistingRooms({
|
|
3703
|
+
link: this.link,
|
|
3704
|
+
roomFilter: this.roomFilter,
|
|
3705
|
+
requestOptions: DEFAULT_REQUEST_OPTIONS
|
|
3706
|
+
}).catch((error) => {
|
|
3707
|
+
this.logger.warn("RoomPresence failed to fetch room snapshot after reconnect", { error });
|
|
3708
|
+
return null;
|
|
3709
|
+
})
|
|
3710
|
+
]);
|
|
3711
|
+
if (accepted) {
|
|
3712
|
+
await this.reconcileRoomsWithSnapshot(accepted);
|
|
3713
|
+
}
|
|
3714
|
+
await Promise.all(
|
|
3715
|
+
this.roster.trackedRoomIds().map((roomId) => this.onRoomEvent?.(roomId, event) ?? Promise.resolve())
|
|
3716
|
+
);
|
|
3717
|
+
}
|
|
3718
|
+
async reconcileRoomsWithSnapshot(accepted) {
|
|
3719
|
+
const acceptedIds = this.autoSubscribeExistingRooms ? [...accepted.keys()] : this.roster.trackedRoomIds().filter((roomId) => accepted.has(roomId));
|
|
3720
|
+
const reconciliation = this.roster.reconcile(acceptedIds);
|
|
3721
|
+
await Promise.all(
|
|
3722
|
+
reconciliation.removed.map(async (roomId) => {
|
|
3723
|
+
await this.leaveRoomTracking(roomId);
|
|
3724
|
+
await this.onRoomLeft?.(roomId);
|
|
3725
|
+
})
|
|
3726
|
+
);
|
|
3727
|
+
await Promise.all(
|
|
3728
|
+
reconciliation.admitting.map(async ([roomId, ticket]) => {
|
|
3729
|
+
const admitted = await this.performAdmission(roomId, ticket);
|
|
3730
|
+
if (admitted) {
|
|
3731
|
+
await this.onRoomJoined?.(roomId, accepted.get(roomId) ?? {});
|
|
3732
|
+
}
|
|
3733
|
+
})
|
|
3734
|
+
);
|
|
3735
|
+
await Promise.all(
|
|
3736
|
+
reconciliation.resync.map(async (roomId) => {
|
|
3737
|
+
try {
|
|
3738
|
+
await this.link.subscribeRoom(roomId);
|
|
3739
|
+
} catch (error) {
|
|
3740
|
+
this.logger.warn("RoomPresence failed to resubscribe surviving room after reconnect", {
|
|
3741
|
+
roomId,
|
|
3742
|
+
error
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
})
|
|
3746
|
+
);
|
|
3747
|
+
}
|
|
2649
3748
|
async unsubscribeRoom(roomId) {
|
|
2650
3749
|
try {
|
|
2651
3750
|
await this.link.unsubscribeRoom(roomId);
|
|
@@ -2690,9 +3789,10 @@ var AgentRuntime = class {
|
|
|
2690
3789
|
executions = /* @__PURE__ */ new Map();
|
|
2691
3790
|
executionWatchers = /* @__PURE__ */ new Map();
|
|
2692
3791
|
logger;
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
3792
|
+
stoppedSignal = new TerminalSignal();
|
|
3793
|
+
lifecycle;
|
|
3794
|
+
startGate = new SingleFlight2();
|
|
3795
|
+
stopGate = new SingleFlight2();
|
|
2696
3796
|
constructor(options) {
|
|
2697
3797
|
this.link = options.link;
|
|
2698
3798
|
this.agentId = options.agentId;
|
|
@@ -2701,7 +3801,7 @@ var AgentRuntime = class {
|
|
|
2701
3801
|
this.onRoomJoined = options.onRoomJoined;
|
|
2702
3802
|
this.onRoomLeft = options.onRoomLeft;
|
|
2703
3803
|
this.onError = options.onError;
|
|
2704
|
-
this.logger = options.logger
|
|
3804
|
+
this.logger = resolveLogger(options.logger);
|
|
2705
3805
|
this.onContactEvent = options.onContactEvent;
|
|
2706
3806
|
this.onParticipantAdded = options.onParticipantAdded;
|
|
2707
3807
|
this.onParticipantRemoved = options.onParticipantRemoved;
|
|
@@ -2713,6 +3813,19 @@ var AgentRuntime = class {
|
|
|
2713
3813
|
maxMessageRetries: options.sessionConfig?.maxMessageRetries ?? 1,
|
|
2714
3814
|
enableContextHydration: options.sessionConfig?.enableContextHydration ?? true
|
|
2715
3815
|
};
|
|
3816
|
+
this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
|
|
3817
|
+
owner: "AgentRuntime",
|
|
3818
|
+
logContext: { agentId: this.agentId },
|
|
3819
|
+
logger: this.logger,
|
|
3820
|
+
isLegalTransition: isLegalRuntimeTransition,
|
|
3821
|
+
onTransition: (state) => {
|
|
3822
|
+
if (state.status === "stopped") {
|
|
3823
|
+
this.stoppedSignal.settle(null);
|
|
3824
|
+
} else if (state.status === "failed") {
|
|
3825
|
+
this.stoppedSignal.settle(state.error);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
2716
3829
|
this.presence = new RoomPresence({
|
|
2717
3830
|
link: this.link,
|
|
2718
3831
|
roomFilter: options.roomFilter,
|
|
@@ -2748,6 +3861,7 @@ var AgentRuntime = class {
|
|
|
2748
3861
|
return;
|
|
2749
3862
|
}
|
|
2750
3863
|
case "message_created":
|
|
3864
|
+
case "reconnected":
|
|
2751
3865
|
await this.getOrCreateExecution(roomId).enqueue(event);
|
|
2752
3866
|
return;
|
|
2753
3867
|
default:
|
|
@@ -2756,65 +3870,138 @@ var AgentRuntime = class {
|
|
|
2756
3870
|
};
|
|
2757
3871
|
this.presence.onContactEvent = this.onContactEvent ?? null;
|
|
2758
3872
|
}
|
|
3873
|
+
/** Current lifecycle state of this runtime. */
|
|
3874
|
+
get state() {
|
|
3875
|
+
return this.lifecycle.state;
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Connect, subscribe, and begin consuming platform events.
|
|
3879
|
+
*
|
|
3880
|
+
* Repeated or concurrent calls join the in-flight start instead of starting a
|
|
3881
|
+
* second consume loop. Calling `start()` while a `stop()` is still in flight
|
|
3882
|
+
* rejects with a `RuntimeStateError`.
|
|
3883
|
+
*/
|
|
2759
3884
|
async start() {
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
3885
|
+
await startWithGate({
|
|
3886
|
+
lifecycle: this.lifecycle,
|
|
3887
|
+
startGate: this.startGate,
|
|
3888
|
+
stopGate: this.stopGate,
|
|
3889
|
+
stoppedSignal: this.stoppedSignal,
|
|
3890
|
+
ownerName: "AgentRuntime",
|
|
3891
|
+
runStart: () => this.runStart()
|
|
3892
|
+
});
|
|
3893
|
+
}
|
|
3894
|
+
async runStart() {
|
|
2766
3895
|
try {
|
|
2767
3896
|
await this.presence.start();
|
|
2768
3897
|
} catch (error) {
|
|
2769
|
-
await this.
|
|
3898
|
+
await this.finishFailedStart();
|
|
2770
3899
|
throw error;
|
|
2771
3900
|
}
|
|
3901
|
+
if (this.lifecycle.is("starting")) {
|
|
3902
|
+
this.lifecycle.transition({ status: "running" }, "started");
|
|
3903
|
+
}
|
|
2772
3904
|
void this.presence.waitUntilStopped().catch((error) => this.failRuntime(error, syntheticRuntimeFailureEvent(this.agentId)));
|
|
2773
3905
|
}
|
|
3906
|
+
async finishFailedStart() {
|
|
3907
|
+
try {
|
|
3908
|
+
await this.handleStartFailure();
|
|
3909
|
+
} catch (cleanupError) {
|
|
3910
|
+
this.markFailed(cleanupError, "start-cleanup-failed");
|
|
3911
|
+
throw cleanupError;
|
|
3912
|
+
}
|
|
3913
|
+
if (this.lifecycle.is("starting")) {
|
|
3914
|
+
this.lifecycle.transition({ status: "stopped" }, "start-failed");
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
2774
3917
|
async handleStartFailure() {
|
|
2775
|
-
this.
|
|
2776
|
-
this.stopping = false;
|
|
3918
|
+
this.presence.abortEventLoop();
|
|
2777
3919
|
await this.link.disconnect();
|
|
2778
3920
|
}
|
|
3921
|
+
/**
|
|
3922
|
+
* Tear the runtime down.
|
|
3923
|
+
*
|
|
3924
|
+
* A concurrent second call joins the in-flight teardown and mirrors its
|
|
3925
|
+
* outcome — including rejecting with the *same* `Error` instance — instead of
|
|
3926
|
+
* reporting a shutdown it did not perform.
|
|
3927
|
+
*/
|
|
2779
3928
|
async stop(timeoutMs) {
|
|
2780
|
-
|
|
3929
|
+
return await this.stopGate.run(() => this.runStop(timeoutMs));
|
|
3930
|
+
}
|
|
3931
|
+
async runStop(timeoutMs) {
|
|
3932
|
+
const pendingStart = this.lifecycle.is("starting") ? this.startGate.pending : null;
|
|
3933
|
+
if (pendingStart) {
|
|
3934
|
+
try {
|
|
3935
|
+
await pendingStart;
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
this.logger.debug("AgentRuntime stop is proceeding after the in-flight start failed", { error });
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
if (this.lifecycle.is("not_started") || this.lifecycle.is("stopped")) {
|
|
2781
3941
|
return true;
|
|
2782
3942
|
}
|
|
2783
|
-
this.
|
|
2784
|
-
this.
|
|
3943
|
+
const fatalError = this.lifecycle.is("failed") ? this.lifecycle.state.error : null;
|
|
3944
|
+
this.startGate.reset();
|
|
3945
|
+
this.lifecycle.transition({ status: "stopping" }, "stop");
|
|
3946
|
+
try {
|
|
3947
|
+
return await this.performStop(timeoutMs, fatalError);
|
|
3948
|
+
} catch (error) {
|
|
3949
|
+
this.markFailed(error, "stop-failed");
|
|
3950
|
+
throw error;
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
async performStop(timeoutMs, fatalError) {
|
|
3954
|
+
const errors = [];
|
|
2785
3955
|
this.presence.abortEventLoop();
|
|
2786
|
-
await this.presence.waitUntilStopped()
|
|
3956
|
+
await isolateTeardown(errors, () => this.presence.waitUntilStopped());
|
|
2787
3957
|
let graceful = true;
|
|
2788
3958
|
await Promise.all(
|
|
2789
3959
|
[...this.executions].map(async ([roomId, execution]) => {
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
}
|
|
3960
|
+
await isolateTeardown(errors, async () => {
|
|
3961
|
+
graceful = await execution.stop(timeoutMs) && graceful;
|
|
3962
|
+
});
|
|
2794
3963
|
this.executions.delete(roomId);
|
|
2795
3964
|
})
|
|
2796
3965
|
);
|
|
2797
|
-
await this.presence.stop();
|
|
3966
|
+
await isolateTeardown(errors, () => this.presence.stop());
|
|
2798
3967
|
for (const roomId of [...this.contexts.keys()]) {
|
|
2799
|
-
await this.onSessionCleanup(roomId);
|
|
3968
|
+
await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
|
|
2800
3969
|
}
|
|
2801
3970
|
this.contexts.clear();
|
|
2802
3971
|
this.executions.clear();
|
|
2803
3972
|
this.executionWatchers.clear();
|
|
2804
|
-
await this.link.disconnect();
|
|
2805
|
-
|
|
2806
|
-
|
|
3973
|
+
await isolateTeardown(errors, () => this.link.disconnect());
|
|
3974
|
+
const failure = this.lifecycle.is("failed") ? this.lifecycle.state.error : fatalError;
|
|
3975
|
+
if (failure) {
|
|
3976
|
+
if (!this.lifecycle.is("failed")) {
|
|
3977
|
+
this.lifecycle.transition({ status: "failed", error: failure }, "stopped-after-failure");
|
|
3978
|
+
}
|
|
3979
|
+
errors.unshift(failure);
|
|
3980
|
+
}
|
|
3981
|
+
if (errors.length > 0) {
|
|
3982
|
+
throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
|
|
2807
3983
|
}
|
|
3984
|
+
this.lifecycle.transition({ status: "stopped" }, "stopped");
|
|
2808
3985
|
return graceful;
|
|
2809
3986
|
}
|
|
2810
3987
|
getContext(roomId) {
|
|
2811
3988
|
return this.contexts.get(roomId);
|
|
2812
3989
|
}
|
|
3990
|
+
/**
|
|
3991
|
+
* Resolve once the runtime has actually stopped, or reject with the fatal
|
|
3992
|
+
* error that ended it.
|
|
3993
|
+
*
|
|
3994
|
+
* A runtime that was never started stays pending until it stops or fails;
|
|
3995
|
+
* starting it does not resolve a pending wait.
|
|
3996
|
+
*/
|
|
2813
3997
|
async waitUntilStopped() {
|
|
2814
|
-
await this.
|
|
2815
|
-
|
|
2816
|
-
|
|
3998
|
+
await this.stoppedSignal.wait();
|
|
3999
|
+
}
|
|
4000
|
+
markFailed(error, trigger) {
|
|
4001
|
+
if (this.lifecycle.is("not_started")) {
|
|
4002
|
+
return false;
|
|
2817
4003
|
}
|
|
4004
|
+
return this.lifecycle.fail(error, trigger);
|
|
2818
4005
|
}
|
|
2819
4006
|
getContexts() {
|
|
2820
4007
|
return [...this.contexts.values()];
|
|
@@ -2827,14 +4014,23 @@ var AgentRuntime = class {
|
|
|
2827
4014
|
await this.getOrCreateExecution(roomId).bootstrapMessage(message);
|
|
2828
4015
|
}
|
|
2829
4016
|
async resetRoomSession(roomId, timeoutMs) {
|
|
2830
|
-
return this.teardownExecution(roomId, timeoutMs);
|
|
4017
|
+
return await this.teardownExecution(roomId, timeoutMs);
|
|
2831
4018
|
}
|
|
2832
4019
|
async teardownExecution(roomId, timeoutMs) {
|
|
2833
4020
|
const execution = this.executions.get(roomId);
|
|
2834
|
-
const
|
|
4021
|
+
const errors = [];
|
|
4022
|
+
let graceful = true;
|
|
4023
|
+
if (execution) {
|
|
4024
|
+
await isolateTeardown(errors, async () => {
|
|
4025
|
+
graceful = await execution.stop(timeoutMs);
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
2835
4028
|
this.executions.delete(roomId);
|
|
2836
4029
|
this.contexts.delete(roomId);
|
|
2837
|
-
await this.onSessionCleanup(roomId);
|
|
4030
|
+
await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
|
|
4031
|
+
if (errors.length > 0) {
|
|
4032
|
+
throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
|
|
4033
|
+
}
|
|
2838
4034
|
return graceful;
|
|
2839
4035
|
}
|
|
2840
4036
|
getOrCreateExecution(roomId) {
|
|
@@ -2895,15 +4091,20 @@ var AgentRuntime = class {
|
|
|
2895
4091
|
return context;
|
|
2896
4092
|
}
|
|
2897
4093
|
async failRuntime(error, event) {
|
|
2898
|
-
if (
|
|
2899
|
-
this.fatalError = error;
|
|
2900
|
-
this.running = false;
|
|
4094
|
+
if (this.markFailed(error, "runtime-error")) {
|
|
2901
4095
|
this.logger.error("Fatal runtime error handling platform event", {
|
|
2902
4096
|
eventType: event.type,
|
|
2903
4097
|
roomId: event.roomId,
|
|
2904
4098
|
error
|
|
2905
4099
|
});
|
|
2906
4100
|
this.notifyOnError(error, event);
|
|
4101
|
+
} else {
|
|
4102
|
+
this.logger.debug("Runtime error after the lifecycle already ended", {
|
|
4103
|
+
status: this.lifecycle.state.status,
|
|
4104
|
+
eventType: event.type,
|
|
4105
|
+
roomId: event.roomId,
|
|
4106
|
+
error
|
|
4107
|
+
});
|
|
2907
4108
|
}
|
|
2908
4109
|
this.presence.abortEventLoop();
|
|
2909
4110
|
}
|
|
@@ -3104,9 +4305,11 @@ var ContactCallbackTools = class {
|
|
|
3104
4305
|
rest;
|
|
3105
4306
|
roomId;
|
|
3106
4307
|
contactTools;
|
|
3107
|
-
|
|
4308
|
+
logger;
|
|
4309
|
+
constructor(rest, roomId, logger) {
|
|
3108
4310
|
this.rest = rest;
|
|
3109
4311
|
this.roomId = roomId;
|
|
4312
|
+
this.logger = resolveLogger(logger);
|
|
3110
4313
|
const hasContactMethods = Boolean(
|
|
3111
4314
|
rest.listContacts || rest.addContact || rest.removeContact || rest.listContactRequests || rest.respondContactRequest
|
|
3112
4315
|
);
|
|
@@ -3141,14 +4344,22 @@ var ContactCallbackTools = class {
|
|
|
3141
4344
|
if (!this.rest.createChatEvent) {
|
|
3142
4345
|
throw new UnsupportedFeatureError("Event sending is not available in current REST adapter");
|
|
3143
4346
|
}
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
4347
|
+
try {
|
|
4348
|
+
return await this.rest.createChatEvent(
|
|
4349
|
+
roomId,
|
|
4350
|
+
{
|
|
4351
|
+
content,
|
|
4352
|
+
messageType,
|
|
4353
|
+
...metadata ? { metadata } : {}
|
|
4354
|
+
}
|
|
4355
|
+
);
|
|
4356
|
+
} catch (error) {
|
|
4357
|
+
this.logger.warn("contact callback chat event send failed", { roomId, messageType, error });
|
|
4358
|
+
return { ok: false, status: "failed", message: asErrorMessage(error) };
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
async sendFailure(failure) {
|
|
4362
|
+
return sendFailureViaEvent(this.sendEvent.bind(this), failure);
|
|
3152
4363
|
}
|
|
3153
4364
|
async addParticipant() {
|
|
3154
4365
|
throw new UnsupportedFeatureError("addParticipant is unavailable for contact callbacks");
|
|
@@ -3379,7 +4590,7 @@ var ContactEventHandler = class {
|
|
|
3379
4590
|
constructor(options) {
|
|
3380
4591
|
this.config = options.config;
|
|
3381
4592
|
this.rest = options.rest;
|
|
3382
|
-
this.logger = options.logger
|
|
4593
|
+
this.logger = resolveLogger(options.logger);
|
|
3383
4594
|
this.onBroadcast = options.onBroadcast;
|
|
3384
4595
|
this.onHubEvent = options.onHubEvent;
|
|
3385
4596
|
this.onHubInit = options.onHubInit;
|
|
@@ -3429,7 +4640,7 @@ var ContactEventHandler = class {
|
|
|
3429
4640
|
this.logger.warn("Contact event callback strategy configured but no onEvent callback provided");
|
|
3430
4641
|
return;
|
|
3431
4642
|
}
|
|
3432
|
-
const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId);
|
|
4643
|
+
const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId, this.logger);
|
|
3433
4644
|
try {
|
|
3434
4645
|
await callback(event, callbackTools);
|
|
3435
4646
|
} catch (error) {
|
|
@@ -3514,7 +4725,8 @@ var ContactEventHandler = class {
|
|
|
3514
4725
|
const msg = event.payload.message ? `
|
|
3515
4726
|
Message: "${event.payload.message}"` : "";
|
|
3516
4727
|
const handle = normalizeHandle(event.payload.from_handle);
|
|
3517
|
-
|
|
4728
|
+
const name = displayName(event.payload.from_name);
|
|
4729
|
+
return `[Contact Request] ${name} (${handle}) wants to connect.${msg}
|
|
3518
4730
|
Request ID: ${event.payload.id}`;
|
|
3519
4731
|
}
|
|
3520
4732
|
case "contact_request_updated": {
|
|
@@ -3533,7 +4745,8 @@ Request ID: ${event.payload.id}`;
|
|
|
3533
4745
|
}
|
|
3534
4746
|
case "contact_added": {
|
|
3535
4747
|
const handle = normalizeHandle(event.payload.handle);
|
|
3536
|
-
|
|
4748
|
+
const name = displayName(event.payload.name);
|
|
4749
|
+
return `[Contact Added] ${name} (${handle}) is now a contact.
|
|
3537
4750
|
Type: ${event.payload.type}, ID: ${event.payload.id}`;
|
|
3538
4751
|
}
|
|
3539
4752
|
case "contact_removed":
|
|
@@ -3545,7 +4758,7 @@ Type: ${event.payload.type}, ID: ${event.payload.id}`;
|
|
|
3545
4758
|
switch (event.type) {
|
|
3546
4759
|
case "contact_added": {
|
|
3547
4760
|
const handle = normalizeHandle(event.payload.handle);
|
|
3548
|
-
return `[Contacts]: ${handle} (${event.payload.name}) is now a contact`;
|
|
4761
|
+
return `[Contacts]: ${handle} (${displayName(event.payload.name)}) is now a contact`;
|
|
3549
4762
|
}
|
|
3550
4763
|
case "contact_removed":
|
|
3551
4764
|
return `[Contacts]: Contact ${event.payload.id} was removed`;
|
|
@@ -3676,10 +4889,14 @@ function normalizeHandle(handle) {
|
|
|
3676
4889
|
if (!handle) return "@unknown";
|
|
3677
4890
|
return handle.startsWith("@") ? handle : `@${handle}`;
|
|
3678
4891
|
}
|
|
4892
|
+
function displayName(name) {
|
|
4893
|
+
return name?.trim() || "Unknown contact";
|
|
4894
|
+
}
|
|
3679
4895
|
|
|
3680
4896
|
// src/runtime/PlatformRuntime.ts
|
|
3681
4897
|
var MAX_MESSAGE_RETRIES = 4294967295;
|
|
3682
4898
|
var isValidRetryCount = (value) => Number.isInteger(value) && value >= 0 && value <= MAX_MESSAGE_RETRIES;
|
|
4899
|
+
var START_CLEANUP_TRIGGER = "start-failed";
|
|
3683
4900
|
var PlatformRuntime = class {
|
|
3684
4901
|
_agentId;
|
|
3685
4902
|
_apiKey;
|
|
@@ -3696,12 +4913,14 @@ var PlatformRuntime = class {
|
|
|
3696
4913
|
_onParticipantRemoved;
|
|
3697
4914
|
_roomFilter;
|
|
3698
4915
|
_contextFactory;
|
|
4916
|
+
lifecycle;
|
|
3699
4917
|
linkInstance;
|
|
3700
4918
|
initPromise = null;
|
|
3701
4919
|
runtime;
|
|
3702
4920
|
contactHandler;
|
|
3703
4921
|
activeAdapter;
|
|
3704
|
-
|
|
4922
|
+
startGate = new SingleFlight2();
|
|
4923
|
+
stopGate = new SingleFlight2();
|
|
3705
4924
|
_agentName = "";
|
|
3706
4925
|
_agentDescription = "";
|
|
3707
4926
|
contactsSubscribed = false;
|
|
@@ -3727,17 +4946,30 @@ var PlatformRuntime = class {
|
|
|
3727
4946
|
this._wsUrl = options.wsUrl;
|
|
3728
4947
|
this._restUrl = options.restUrl;
|
|
3729
4948
|
this.linkInstance = options.link;
|
|
3730
|
-
this.linkOptions =
|
|
4949
|
+
this.linkOptions = {
|
|
4950
|
+
...options.linkOptions,
|
|
4951
|
+
logger: options.logger ?? options.linkOptions?.logger
|
|
4952
|
+
};
|
|
3731
4953
|
this.preprocessor = options.preprocessor ?? new DefaultPreprocessor();
|
|
3732
4954
|
this.sessionConfig = options.sessionConfig;
|
|
3733
4955
|
this.contactConfig = options.contactConfig;
|
|
3734
4956
|
this.agentConfig = options.agentConfig;
|
|
3735
|
-
this.logger = options.logger
|
|
4957
|
+
this.logger = resolveLogger(options.logger);
|
|
3736
4958
|
this.configuredIdentity = options.identity;
|
|
3737
4959
|
this._onParticipantAdded = options.onParticipantAdded;
|
|
3738
4960
|
this._onParticipantRemoved = options.onParticipantRemoved;
|
|
3739
4961
|
this._roomFilter = options.roomFilter;
|
|
3740
4962
|
this._contextFactory = options.contextFactory;
|
|
4963
|
+
this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
|
|
4964
|
+
owner: "PlatformRuntime",
|
|
4965
|
+
logContext: { agentId: this._agentId },
|
|
4966
|
+
logger: this.logger,
|
|
4967
|
+
isLegalTransition: isLegalRuntimeTransition
|
|
4968
|
+
});
|
|
4969
|
+
}
|
|
4970
|
+
/** Current lifecycle state of this runtime. */
|
|
4971
|
+
get state() {
|
|
4972
|
+
return this.lifecycle.state;
|
|
3741
4973
|
}
|
|
3742
4974
|
get link() {
|
|
3743
4975
|
if (!this.linkInstance) {
|
|
@@ -3757,6 +4989,14 @@ var PlatformRuntime = class {
|
|
|
3757
4989
|
get contactConfiguration() {
|
|
3758
4990
|
return this.contactConfig;
|
|
3759
4991
|
}
|
|
4992
|
+
/**
|
|
4993
|
+
* Whether the contacts channel subscription is currently held.
|
|
4994
|
+
*
|
|
4995
|
+
* @deprecated Read {@link PlatformRuntime.state} for lifecycle questions: this
|
|
4996
|
+
* flag is only ever `true` while the runtime is running and is cleared on
|
|
4997
|
+
* teardown, so it conflates "contacts are subscribed" with "the runtime is
|
|
4998
|
+
* alive".
|
|
4999
|
+
*/
|
|
3760
5000
|
get isContactsSubscribed() {
|
|
3761
5001
|
return this.contactsSubscribed;
|
|
3762
5002
|
}
|
|
@@ -3779,8 +5019,7 @@ var PlatformRuntime = class {
|
|
|
3779
5019
|
agentId: this._agentId,
|
|
3780
5020
|
apiKey: this._apiKey,
|
|
3781
5021
|
wsUrl: this._wsUrl,
|
|
3782
|
-
restUrl: this._restUrl
|
|
3783
|
-
logger: this.logger
|
|
5022
|
+
restUrl: this._restUrl
|
|
3784
5023
|
});
|
|
3785
5024
|
}
|
|
3786
5025
|
if (this.configuredIdentity) {
|
|
@@ -3792,11 +5031,49 @@ var PlatformRuntime = class {
|
|
|
3792
5031
|
this._agentName = me.name;
|
|
3793
5032
|
this._agentDescription = me.description ?? "";
|
|
3794
5033
|
}
|
|
5034
|
+
/**
|
|
5035
|
+
* Connect the adapter to the platform.
|
|
5036
|
+
*
|
|
5037
|
+
* Repeated or concurrent calls join the in-flight start. Calling `start()`
|
|
5038
|
+
* while a `stop()` is still in flight rejects with a {@link RuntimeStateError}.
|
|
5039
|
+
* A successful start also re-arms teardown, so a runtime whose previous
|
|
5040
|
+
* `stop()` failed can be shut down properly on the next attempt.
|
|
5041
|
+
*/
|
|
3795
5042
|
async start(adapter) {
|
|
5043
|
+
await startWithGate({
|
|
5044
|
+
lifecycle: this.lifecycle,
|
|
5045
|
+
startGate: this.startGate,
|
|
5046
|
+
stopGate: this.stopGate,
|
|
5047
|
+
ownerName: "PlatformRuntime",
|
|
5048
|
+
runStart: () => this.runStart(adapter)
|
|
5049
|
+
});
|
|
5050
|
+
}
|
|
5051
|
+
async runStart(adapter) {
|
|
5052
|
+
const startState = this.lifecycle.state;
|
|
5053
|
+
try {
|
|
5054
|
+
await this.doStart(adapter, startState);
|
|
5055
|
+
} catch (error) {
|
|
5056
|
+
if (this.lifecycle.isCurrent(startState)) {
|
|
5057
|
+
this.lifecycle.fail(error, START_CLEANUP_TRIGGER);
|
|
5058
|
+
}
|
|
5059
|
+
throw error;
|
|
5060
|
+
}
|
|
5061
|
+
if (this.lifecycle.isCurrent(startState)) {
|
|
5062
|
+
this.lifecycle.transition({ status: "running" }, "started");
|
|
5063
|
+
}
|
|
5064
|
+
}
|
|
5065
|
+
async doStart(adapter, startState) {
|
|
5066
|
+
const assertNotSuperseded = () => {
|
|
5067
|
+
if (!this.lifecycle.isCurrent(startState)) {
|
|
5068
|
+
throw new RuntimeStateError("PlatformRuntime start was superseded by stop()");
|
|
5069
|
+
}
|
|
5070
|
+
};
|
|
3796
5071
|
await this.initialize();
|
|
3797
|
-
|
|
5072
|
+
assertNotSuperseded();
|
|
3798
5073
|
this.activeAdapter = adapter;
|
|
3799
5074
|
try {
|
|
5075
|
+
await adapter.onStarted(this._agentName, this._agentDescription);
|
|
5076
|
+
assertNotSuperseded();
|
|
3800
5077
|
this.contactHandler = new ContactEventHandler({
|
|
3801
5078
|
config: this.contactConfig ?? { strategy: "disabled" },
|
|
3802
5079
|
rest: this.link.rest,
|
|
@@ -3810,7 +5087,18 @@ var PlatformRuntime = class {
|
|
|
3810
5087
|
onHubEvent: async (roomId, event) => {
|
|
3811
5088
|
const runtime = this.runtime;
|
|
3812
5089
|
if (!runtime) return;
|
|
3813
|
-
|
|
5090
|
+
try {
|
|
5091
|
+
await runtime.enqueueEvent(roomId, event);
|
|
5092
|
+
} catch (error) {
|
|
5093
|
+
if (!(error instanceof RuntimeStateError)) {
|
|
5094
|
+
throw error;
|
|
5095
|
+
}
|
|
5096
|
+
this.logger.error("Dropped contact hub event for a stopped room execution", {
|
|
5097
|
+
roomId,
|
|
5098
|
+
eventType: event.type,
|
|
5099
|
+
error: error.message
|
|
5100
|
+
});
|
|
5101
|
+
}
|
|
3814
5102
|
},
|
|
3815
5103
|
onHubInit: async (roomId, systemPrompt) => {
|
|
3816
5104
|
const runtime = this.runtime;
|
|
@@ -3833,10 +5121,14 @@ var PlatformRuntime = class {
|
|
|
3833
5121
|
contextFactory: this._contextFactory
|
|
3834
5122
|
});
|
|
3835
5123
|
await this.runtime.start();
|
|
5124
|
+
assertNotSuperseded();
|
|
3836
5125
|
this.contactsSubscribed = Boolean(this.link.capabilities.contacts);
|
|
3837
5126
|
} catch (error) {
|
|
5127
|
+
if (!this.lifecycle.isCurrent(startState)) {
|
|
5128
|
+
throw error;
|
|
5129
|
+
}
|
|
3838
5130
|
try {
|
|
3839
|
-
await this.
|
|
5131
|
+
await this.beginStop(void 0, START_CLEANUP_TRIGGER);
|
|
3840
5132
|
} catch (stopError) {
|
|
3841
5133
|
throw new AggregateError(
|
|
3842
5134
|
[error, stopError],
|
|
@@ -3846,49 +5138,63 @@ var PlatformRuntime = class {
|
|
|
3846
5138
|
throw error;
|
|
3847
5139
|
}
|
|
3848
5140
|
}
|
|
5141
|
+
/**
|
|
5142
|
+
* Tear the runtime down and release the platform connection.
|
|
5143
|
+
*
|
|
5144
|
+
* A concurrent second call joins the in-flight teardown and mirrors its
|
|
5145
|
+
* outcome — including rejecting with the *same* `Error` instance — rather than
|
|
5146
|
+
* reporting a graceful shutdown it did not perform. A failed teardown does not
|
|
5147
|
+
* disable future ones: a subsequent `start()` re-arms `stop()`.
|
|
5148
|
+
*/
|
|
3849
5149
|
async stop(timeoutMs) {
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
5150
|
+
return await this.beginStop(timeoutMs, "stop");
|
|
5151
|
+
}
|
|
5152
|
+
async beginStop(timeoutMs, trigger) {
|
|
5153
|
+
return await this.stopGate.run(() => this.runStop(timeoutMs, trigger));
|
|
5154
|
+
}
|
|
5155
|
+
async runStop(timeoutMs, trigger) {
|
|
5156
|
+
this.startGate.reset();
|
|
3853
5157
|
const runtime = this.runtime;
|
|
3854
5158
|
const adapter = this.activeAdapter;
|
|
3855
5159
|
if (!runtime && !adapter) {
|
|
5160
|
+
if (this.lifecycle.is("failed")) {
|
|
5161
|
+
this.logger.debug("PlatformRuntime stop is resurfacing the recorded start failure", { error: this.lifecycle.state.error });
|
|
5162
|
+
throw this.lifecycle.state.error;
|
|
5163
|
+
}
|
|
5164
|
+
if (this.lifecycle.is("starting")) {
|
|
5165
|
+
this.lifecycle.transition({ status: "stopped" }, trigger);
|
|
5166
|
+
}
|
|
3856
5167
|
return true;
|
|
3857
5168
|
}
|
|
3858
|
-
this.stopping
|
|
5169
|
+
this.lifecycle.transition({ status: "stopping" }, trigger);
|
|
3859
5170
|
this.runtime = void 0;
|
|
3860
5171
|
this.contactHandler = void 0;
|
|
3861
5172
|
this.contactsSubscribed = false;
|
|
3862
5173
|
this.activeAdapter = void 0;
|
|
3863
5174
|
let graceful = true;
|
|
3864
|
-
|
|
5175
|
+
const errors = [];
|
|
3865
5176
|
if (runtime) {
|
|
3866
|
-
|
|
5177
|
+
await isolateTeardown(errors, async () => {
|
|
3867
5178
|
graceful = await runtime.stop(timeoutMs);
|
|
3868
|
-
}
|
|
3869
|
-
runtimeError = error;
|
|
3870
|
-
}
|
|
3871
|
-
}
|
|
3872
|
-
try {
|
|
3873
|
-
await adapter?.onRuntimeStop?.();
|
|
3874
|
-
} catch (error) {
|
|
3875
|
-
if (runtimeError) {
|
|
3876
|
-
throw new AggregateError(
|
|
3877
|
-
[runtimeError, error],
|
|
3878
|
-
"PlatformRuntime stop failed and adapter cleanup also failed"
|
|
3879
|
-
);
|
|
3880
|
-
}
|
|
3881
|
-
throw error;
|
|
5179
|
+
});
|
|
3882
5180
|
}
|
|
3883
|
-
|
|
3884
|
-
|
|
5181
|
+
await isolateTeardown(errors, () => Promise.resolve(adapter?.onRuntimeStop?.()));
|
|
5182
|
+
if (errors.length > 0) {
|
|
5183
|
+
throw this.recordStopFailure(
|
|
5184
|
+
combineTeardownErrors(errors, "PlatformRuntime stop failed and adapter cleanup also failed")
|
|
5185
|
+
);
|
|
3885
5186
|
}
|
|
3886
|
-
this.
|
|
5187
|
+
this.lifecycle.transition({ status: "stopped" }, "stopped");
|
|
3887
5188
|
return graceful;
|
|
3888
5189
|
}
|
|
3889
5190
|
async [Symbol.asyncDispose]() {
|
|
3890
5191
|
await this.stop();
|
|
3891
5192
|
}
|
|
5193
|
+
recordStopFailure(error) {
|
|
5194
|
+
const failure = toLifecycleError(error);
|
|
5195
|
+
this.lifecycle.fail(failure, "stop-failed");
|
|
5196
|
+
return failure;
|
|
5197
|
+
}
|
|
3892
5198
|
async runForever() {
|
|
3893
5199
|
if (!this.runtime) {
|
|
3894
5200
|
throw new RuntimeStateError("Runtime not started");
|
|
@@ -4002,6 +5308,10 @@ async function runWithGracefulShutdown(agent, options) {
|
|
|
4002
5308
|
export {
|
|
4003
5309
|
deriveDefaultRestUrl,
|
|
4004
5310
|
BandLink,
|
|
5311
|
+
isLegalRuntimeTransition,
|
|
5312
|
+
SingleFlight2 as SingleFlight,
|
|
5313
|
+
LifecycleTracker,
|
|
5314
|
+
startWithGate,
|
|
4005
5315
|
Execution,
|
|
4006
5316
|
AgentTools,
|
|
4007
5317
|
ExecutionContext,
|