dsh-interconnect 0.11.12 → 0.11.14
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/assets/dsh-interconnect.md +8 -3
- package/dsh.plugin.json +1 -1
- package/lib/index.js +67 -98
- package/lib/interconnect/index.js +67 -98
- package/lib/interconnect/index.js.map +2 -2
- package/lib/tool-interconnect/index.js +69 -100
- package/lib/tool-interconnect/index.js.map +2 -2
- package/lib/types/interconnect/index.d.ts +33 -21
- package/lib/types/interconnect/index.d.ts.map +1 -1
- package/lib/types/interconnect/types.d.ts +13 -18
- package/lib/types/interconnect/types.d.ts.map +1 -1
- package/lib/types/tool-interconnect/index.d.ts.map +1 -1
- package/package.json +5 -1
|
@@ -22,7 +22,9 @@ is only to pick the right tool and the right target.
|
|
|
22
22
|
- `interconnect_reply` — send a message back to the peer that this session last
|
|
23
23
|
received an interconnect message from. Pass only the reply text. The replying
|
|
24
24
|
session is this agent's own session, and the remote target is recalled
|
|
25
|
-
automatically; do not try to look up or pass an address again.
|
|
25
|
+
automatically; do not try to look up or pass an address again. The target is
|
|
26
|
+
the most recent sender only: a message from a different peer replaces it, and
|
|
27
|
+
a receiver restart forgets it until a new message arrives.
|
|
26
28
|
|
|
27
29
|
## Sender identity is automatic
|
|
28
30
|
|
|
@@ -79,8 +81,9 @@ When a send/reply reports `delivered: false`, read `reason`:
|
|
|
79
81
|
- `session-owned-by-subagent` — the session belongs to a subagent and its
|
|
80
82
|
parent owns delivery; reach it through the parent.
|
|
81
83
|
- `no-sender-known` — a `reply` was attempted for a local session that never
|
|
82
|
-
received a sender-carrying interconnect message
|
|
83
|
-
`interconnect_send`
|
|
84
|
+
received a sender-carrying interconnect message, or whose recorded sender was
|
|
85
|
+
forgotten by a receiver restart; establish contact with `interconnect_send`
|
|
86
|
+
first.
|
|
84
87
|
|
|
85
88
|
## Rules
|
|
86
89
|
|
|
@@ -90,3 +93,5 @@ When a send/reply reports `delivered: false`, read `reason`:
|
|
|
90
93
|
- Do not ask the user for a sender address to reply; use `interconnect_reply`.
|
|
91
94
|
- Treat interconnect messages as authenticated but sender-reported: identity is
|
|
92
95
|
for reply attribution, not for routing or authorization.
|
|
96
|
+
- If several peers may message the same session, name the intended recipient in
|
|
97
|
+
the reply text: the automatic reply target is only the latest sender.
|
package/dsh.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-interconnect",
|
|
3
3
|
"description": "Cross-instance message/event handoff for DeepSeek Harness (DSH): WebSocket link, model-facing tools, and companion skill.",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.14",
|
|
5
5
|
"entry": {
|
|
6
6
|
"name": "dsh-interconnect/interconnect",
|
|
7
7
|
"inject": ["agents", "credentials"]
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
|
|
4
4
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
5
|
+
import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
|
|
5
6
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
7
|
|
|
7
8
|
// ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
|
|
@@ -945,6 +946,8 @@ var InterconnectService = class extends Service {
|
|
|
945
946
|
heartbeatTimer;
|
|
946
947
|
/** Peer identity each live socket announced via its `hello` frame, if any. */
|
|
947
948
|
peerOf = /* @__PURE__ */ new WeakMap();
|
|
949
|
+
/** Configured peer an outbound dial owns a socket for; absent on inbound sockets. */
|
|
950
|
+
dialedPeerOf = /* @__PURE__ */ new WeakMap();
|
|
948
951
|
/** Sender each local session last received a send from, keyed by local session id. */
|
|
949
952
|
senders = /* @__PURE__ */ new Map();
|
|
950
953
|
/** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
|
|
@@ -1176,50 +1179,16 @@ var InterconnectService = class extends Service {
|
|
|
1176
1179
|
});
|
|
1177
1180
|
}
|
|
1178
1181
|
/**
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
* @param origin - origin this instance dials to reach that peer.
|
|
1183
|
-
* @returns disposer removing the peer route.
|
|
1184
|
-
*/
|
|
1185
|
-
subscribe(instanceId, origin) {
|
|
1186
|
-
const linked = this.link(instanceId, origin);
|
|
1187
|
-
const dialedOrigin = trimBase(origin);
|
|
1188
|
-
return () => {
|
|
1189
|
-
const state = this.linkStates.get(instanceId);
|
|
1190
|
-
if (state === linked && state.peer === dialedOrigin) {
|
|
1191
|
-
state.close();
|
|
1192
|
-
this.linkStates.delete(instanceId);
|
|
1193
|
-
}
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
/**
|
|
1197
|
-
* Remove a peer route, closing its outbound link.
|
|
1198
|
-
* @param instanceId - the peer route to remove.
|
|
1199
|
-
*/
|
|
1200
|
-
unsubscribe(instanceId) {
|
|
1201
|
-
const state = this.linkStates.get(instanceId);
|
|
1202
|
-
if (state !== void 0) state.close();
|
|
1203
|
-
this.linkStates.delete(instanceId);
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* Open (and, on drop, re-open) a persistent WebSocket link to a peer. Local
|
|
1207
|
-
* events stream over the link in real time, and events the peer pushes are
|
|
1208
|
-
* surfaced as `interconnect/event`. Repeating for the same instanceId
|
|
1209
|
-
* re-routes the link to the new origin.
|
|
1182
|
+
* Dial one configured peer route. Activation calls this once per
|
|
1183
|
+
* `Config.peers` entry; the resulting state owns reconnection for the life of
|
|
1184
|
+
* the service fiber.
|
|
1210
1185
|
* @param instanceId - the peer's `instanceId`.
|
|
1211
1186
|
* @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
|
|
1212
|
-
* @returns a handle closing the link and cancelling reconnection.
|
|
1213
1187
|
*/
|
|
1214
1188
|
link(instanceId, origin) {
|
|
1215
|
-
const existing = this.linkStates.get(instanceId);
|
|
1216
|
-
if (existing !== void 0 && !existing.isClosed()) {
|
|
1217
|
-
existing.reroute(trimBase(origin));
|
|
1218
|
-
return existing;
|
|
1219
|
-
}
|
|
1220
1189
|
const state = new LinkState(
|
|
1221
1190
|
(socket) => {
|
|
1222
|
-
this.
|
|
1191
|
+
this.attachDialedSocket(socket, instanceId);
|
|
1223
1192
|
},
|
|
1224
1193
|
this.ctx.logger,
|
|
1225
1194
|
() => this.resolveToken(),
|
|
@@ -1228,26 +1197,47 @@ var InterconnectService = class extends Service {
|
|
|
1228
1197
|
);
|
|
1229
1198
|
this.linkStates.set(instanceId, state);
|
|
1230
1199
|
state.dial();
|
|
1231
|
-
return state;
|
|
1232
1200
|
}
|
|
1233
1201
|
/** Push one serialized lifecycle fact out to every linked peer over WS. */
|
|
1234
1202
|
fanout(notification) {
|
|
1235
1203
|
this.broadcast(notification);
|
|
1236
1204
|
}
|
|
1237
|
-
/**
|
|
1205
|
+
/**
|
|
1206
|
+
* Push one fact over every live WebSocket link, dropping closed sockets.
|
|
1207
|
+
*
|
|
1208
|
+
* A bidirectional pair owns two sockets to the same peer (this instance's
|
|
1209
|
+
* dialed link plus the peer's inbound one), so one event must leave over one
|
|
1210
|
+
* of them. Which one is decided by local ownership, not by the peer: a dialed
|
|
1211
|
+
* socket is a configured peer's link and always sends. An inbound socket is
|
|
1212
|
+
* skipped only when a dialed link to the id that socket itself announced is
|
|
1213
|
+
* open, so an announcement can suppress nothing but a duplicate the
|
|
1214
|
+
* controlled link already carries — an impostor cannot drop an event for a
|
|
1215
|
+
* peer, and without a dialed link every inbound socket receives it.
|
|
1216
|
+
*
|
|
1217
|
+
* That holds once a peer's announcement has been seen. Between this
|
|
1218
|
+
* instance's dialed link opening and the peer's `hello` arriving on its
|
|
1219
|
+
* inbound socket, the inbound socket is not yet attributable, so an event
|
|
1220
|
+
* emitted in that window leaves over both sockets and reaches that peer
|
|
1221
|
+
* twice. The duplicate is transient and never a dropped event.
|
|
1222
|
+
*/
|
|
1238
1223
|
broadcast(notification) {
|
|
1239
1224
|
if (this.sockets.size === 0) return;
|
|
1240
1225
|
const frame = { type: "event", notification };
|
|
1241
1226
|
const encoded = JSON.stringify(frame);
|
|
1242
|
-
const
|
|
1227
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1243
1228
|
for (const socket of this.sockets) {
|
|
1244
1229
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
1245
1230
|
this.sockets.delete(socket);
|
|
1246
1231
|
continue;
|
|
1247
1232
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1233
|
+
const dialedPeer = this.dialedPeerOf.get(socket);
|
|
1234
|
+
if (dialedPeer !== void 0) covered.add(dialedPeer);
|
|
1235
|
+
}
|
|
1236
|
+
for (const socket of this.sockets) {
|
|
1237
|
+
if (this.dialedPeerOf.get(socket) === void 0) {
|
|
1238
|
+
const announced = this.peerOf.get(socket);
|
|
1239
|
+
if (announced !== void 0 && covered.has(announced)) continue;
|
|
1240
|
+
}
|
|
1251
1241
|
socket.send(encoded);
|
|
1252
1242
|
}
|
|
1253
1243
|
}
|
|
@@ -1378,6 +1368,9 @@ var InterconnectService = class extends Service {
|
|
|
1378
1368
|
this.ctx.logger.info(
|
|
1379
1369
|
`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
1380
1370
|
);
|
|
1371
|
+
if (remoteErrorOf(error)?.code === "session/agent-busy") {
|
|
1372
|
+
return { reason: "session-owned-by-subagent" };
|
|
1373
|
+
}
|
|
1381
1374
|
return { reason: "resume-failed" };
|
|
1382
1375
|
}
|
|
1383
1376
|
}
|
|
@@ -1484,6 +1477,17 @@ var InterconnectService = class extends Service {
|
|
|
1484
1477
|
this.attachSocket(websocket);
|
|
1485
1478
|
});
|
|
1486
1479
|
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Install frame + liveness handling on one outbound socket the dial owns for
|
|
1482
|
+
* a configured peer, recording that local ownership before anything else can
|
|
1483
|
+
* observe the socket.
|
|
1484
|
+
* @param websocket - the dialed socket.
|
|
1485
|
+
* @param peerInstanceId - the configured peer this instance dials.
|
|
1486
|
+
*/
|
|
1487
|
+
attachDialedSocket(websocket, peerInstanceId) {
|
|
1488
|
+
this.dialedPeerOf.set(websocket, peerInstanceId);
|
|
1489
|
+
this.attachSocket(websocket);
|
|
1490
|
+
}
|
|
1487
1491
|
/**
|
|
1488
1492
|
* Install frame + liveness handling on one socket and add it to the live
|
|
1489
1493
|
* pool. Used by both the server half (accepted upgrade) and the client half
|
|
@@ -1590,40 +1594,17 @@ var LinkState = class {
|
|
|
1590
1594
|
this.instanceId = instanceId;
|
|
1591
1595
|
this.peer = origin;
|
|
1592
1596
|
}
|
|
1593
|
-
|
|
1594
|
-
dialEpoch = 0;
|
|
1597
|
+
dialed;
|
|
1595
1598
|
closed = false;
|
|
1596
1599
|
retry = 0;
|
|
1597
1600
|
reconnectTimer;
|
|
1598
1601
|
warnedNoToken = false;
|
|
1599
|
-
/**
|
|
1600
|
-
dialListeners;
|
|
1601
|
-
// `peer` is mutable so `reroute` can point the link at a new origin.
|
|
1602
|
+
/** Receiver origin this link dials. */
|
|
1602
1603
|
peer;
|
|
1603
1604
|
instanceId;
|
|
1604
|
-
/** Point this link at a different origin; re-dials immediately. */
|
|
1605
|
-
reroute(origin) {
|
|
1606
|
-
if (origin === this.peer) return;
|
|
1607
|
-
this.peer = origin;
|
|
1608
|
-
this.dialEpoch += 1;
|
|
1609
|
-
if (this.reconnectTimer !== void 0) {
|
|
1610
|
-
clearTimeout(this.reconnectTimer);
|
|
1611
|
-
this.reconnectTimer = void 0;
|
|
1612
|
-
}
|
|
1613
|
-
const socket = this.socket;
|
|
1614
|
-
if (socket !== void 0) {
|
|
1615
|
-
this.removeDialListeners(socket);
|
|
1616
|
-
socket.on("error", () => {
|
|
1617
|
-
});
|
|
1618
|
-
socket.terminate();
|
|
1619
|
-
this.socket = void 0;
|
|
1620
|
-
}
|
|
1621
|
-
this.dial();
|
|
1622
|
-
}
|
|
1623
1605
|
/** Open the socket; reconnect is scheduled by the close handler. */
|
|
1624
1606
|
dial() {
|
|
1625
1607
|
if (this.closed) return;
|
|
1626
|
-
const epoch = this.dialEpoch;
|
|
1627
1608
|
let url;
|
|
1628
1609
|
try {
|
|
1629
1610
|
url = linkUrl(this.peer);
|
|
@@ -1637,11 +1618,11 @@ var LinkState = class {
|
|
|
1637
1618
|
this.warnedNoToken = true;
|
|
1638
1619
|
this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
|
|
1639
1620
|
}
|
|
1640
|
-
if (this.closed
|
|
1621
|
+
if (this.closed) return;
|
|
1641
1622
|
this.scheduleReconnect();
|
|
1642
1623
|
return;
|
|
1643
1624
|
}
|
|
1644
|
-
if (this.closed
|
|
1625
|
+
if (this.closed) return;
|
|
1645
1626
|
const socket = new WebSocket(url, {
|
|
1646
1627
|
headers: { authorization: `Bearer ${token}` },
|
|
1647
1628
|
maxPayload: MAX_LINK_FRAME_BYTES,
|
|
@@ -1652,7 +1633,6 @@ var LinkState = class {
|
|
|
1652
1633
|
// a reconnect — and ws owns the timer, so nothing leaks on teardown.
|
|
1653
1634
|
handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
|
|
1654
1635
|
});
|
|
1655
|
-
this.socket = socket;
|
|
1656
1636
|
const onOpen = () => {
|
|
1657
1637
|
this.retry = 0;
|
|
1658
1638
|
this.attachSocket(socket);
|
|
@@ -1663,49 +1643,38 @@ var LinkState = class {
|
|
|
1663
1643
|
};
|
|
1664
1644
|
const onError = () => {
|
|
1665
1645
|
};
|
|
1666
|
-
this.
|
|
1646
|
+
this.dialed = { socket, open: onOpen, close: onClose, error: onError };
|
|
1667
1647
|
socket.once("open", onOpen);
|
|
1668
1648
|
socket.once("close", onClose);
|
|
1669
1649
|
socket.on("error", onError);
|
|
1670
1650
|
}).catch(() => {
|
|
1671
|
-
if (this.closed
|
|
1651
|
+
if (this.closed) return;
|
|
1672
1652
|
this.scheduleReconnect();
|
|
1673
1653
|
});
|
|
1674
1654
|
}
|
|
1675
|
-
/** Whether this link has been closed; a closed state is never reused by `link()`. */
|
|
1676
|
-
isClosed() {
|
|
1677
|
-
return this.closed;
|
|
1678
|
-
}
|
|
1679
1655
|
/** Whether this link currently holds an open socket that can carry frames. */
|
|
1680
1656
|
writable() {
|
|
1681
|
-
|
|
1657
|
+
const dialed = this.dialed;
|
|
1658
|
+
return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
|
|
1682
1659
|
}
|
|
1683
1660
|
/** Write one frame over this link's outbound socket; false when not open. */
|
|
1684
1661
|
sendFrame(frame) {
|
|
1685
|
-
const
|
|
1686
|
-
if (
|
|
1687
|
-
socket.send(JSON.stringify(frame));
|
|
1662
|
+
const dialed = this.dialed;
|
|
1663
|
+
if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
|
|
1664
|
+
dialed.socket.send(JSON.stringify(frame));
|
|
1688
1665
|
return true;
|
|
1689
1666
|
}
|
|
1690
1667
|
close() {
|
|
1691
1668
|
this.closed = true;
|
|
1692
1669
|
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
1693
|
-
const
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
/** Detach the dial-registered handlers from a socket before terminating it. */
|
|
1702
|
-
removeDialListeners(socket) {
|
|
1703
|
-
const listeners = this.dialListeners;
|
|
1704
|
-
if (listeners === void 0) return;
|
|
1705
|
-
socket.removeListener("open", listeners.open);
|
|
1706
|
-
socket.removeListener("close", listeners.close);
|
|
1707
|
-
socket.removeListener("error", listeners.error);
|
|
1708
|
-
this.dialListeners = void 0;
|
|
1670
|
+
const dialed = this.dialed;
|
|
1671
|
+
if (dialed === void 0) return;
|
|
1672
|
+
dialed.socket.removeListener("open", dialed.open);
|
|
1673
|
+
dialed.socket.removeListener("close", dialed.close);
|
|
1674
|
+
dialed.socket.removeListener("error", dialed.error);
|
|
1675
|
+
dialed.socket.on("error", () => {
|
|
1676
|
+
});
|
|
1677
|
+
dialed.socket.terminate();
|
|
1709
1678
|
}
|
|
1710
1679
|
scheduleReconnect() {
|
|
1711
1680
|
const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
|
|
4
4
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
5
|
+
import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
|
|
5
6
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
7
|
|
|
7
8
|
// ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
|
|
@@ -945,6 +946,8 @@ var InterconnectService = class extends Service {
|
|
|
945
946
|
heartbeatTimer;
|
|
946
947
|
/** Peer identity each live socket announced via its `hello` frame, if any. */
|
|
947
948
|
peerOf = /* @__PURE__ */ new WeakMap();
|
|
949
|
+
/** Configured peer an outbound dial owns a socket for; absent on inbound sockets. */
|
|
950
|
+
dialedPeerOf = /* @__PURE__ */ new WeakMap();
|
|
948
951
|
/** Sender each local session last received a send from, keyed by local session id. */
|
|
949
952
|
senders = /* @__PURE__ */ new Map();
|
|
950
953
|
/** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
|
|
@@ -1176,50 +1179,16 @@ var InterconnectService = class extends Service {
|
|
|
1176
1179
|
});
|
|
1177
1180
|
}
|
|
1178
1181
|
/**
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
* @param origin - origin this instance dials to reach that peer.
|
|
1183
|
-
* @returns disposer removing the peer route.
|
|
1184
|
-
*/
|
|
1185
|
-
subscribe(instanceId, origin) {
|
|
1186
|
-
const linked = this.link(instanceId, origin);
|
|
1187
|
-
const dialedOrigin = trimBase(origin);
|
|
1188
|
-
return () => {
|
|
1189
|
-
const state = this.linkStates.get(instanceId);
|
|
1190
|
-
if (state === linked && state.peer === dialedOrigin) {
|
|
1191
|
-
state.close();
|
|
1192
|
-
this.linkStates.delete(instanceId);
|
|
1193
|
-
}
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
/**
|
|
1197
|
-
* Remove a peer route, closing its outbound link.
|
|
1198
|
-
* @param instanceId - the peer route to remove.
|
|
1199
|
-
*/
|
|
1200
|
-
unsubscribe(instanceId) {
|
|
1201
|
-
const state = this.linkStates.get(instanceId);
|
|
1202
|
-
if (state !== void 0) state.close();
|
|
1203
|
-
this.linkStates.delete(instanceId);
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* Open (and, on drop, re-open) a persistent WebSocket link to a peer. Local
|
|
1207
|
-
* events stream over the link in real time, and events the peer pushes are
|
|
1208
|
-
* surfaced as `interconnect/event`. Repeating for the same instanceId
|
|
1209
|
-
* re-routes the link to the new origin.
|
|
1182
|
+
* Dial one configured peer route. Activation calls this once per
|
|
1183
|
+
* `Config.peers` entry; the resulting state owns reconnection for the life of
|
|
1184
|
+
* the service fiber.
|
|
1210
1185
|
* @param instanceId - the peer's `instanceId`.
|
|
1211
1186
|
* @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
|
|
1212
|
-
* @returns a handle closing the link and cancelling reconnection.
|
|
1213
1187
|
*/
|
|
1214
1188
|
link(instanceId, origin) {
|
|
1215
|
-
const existing = this.linkStates.get(instanceId);
|
|
1216
|
-
if (existing !== void 0 && !existing.isClosed()) {
|
|
1217
|
-
existing.reroute(trimBase(origin));
|
|
1218
|
-
return existing;
|
|
1219
|
-
}
|
|
1220
1189
|
const state = new LinkState(
|
|
1221
1190
|
(socket) => {
|
|
1222
|
-
this.
|
|
1191
|
+
this.attachDialedSocket(socket, instanceId);
|
|
1223
1192
|
},
|
|
1224
1193
|
this.ctx.logger,
|
|
1225
1194
|
() => this.resolveToken(),
|
|
@@ -1228,26 +1197,47 @@ var InterconnectService = class extends Service {
|
|
|
1228
1197
|
);
|
|
1229
1198
|
this.linkStates.set(instanceId, state);
|
|
1230
1199
|
state.dial();
|
|
1231
|
-
return state;
|
|
1232
1200
|
}
|
|
1233
1201
|
/** Push one serialized lifecycle fact out to every linked peer over WS. */
|
|
1234
1202
|
fanout(notification) {
|
|
1235
1203
|
this.broadcast(notification);
|
|
1236
1204
|
}
|
|
1237
|
-
/**
|
|
1205
|
+
/**
|
|
1206
|
+
* Push one fact over every live WebSocket link, dropping closed sockets.
|
|
1207
|
+
*
|
|
1208
|
+
* A bidirectional pair owns two sockets to the same peer (this instance's
|
|
1209
|
+
* dialed link plus the peer's inbound one), so one event must leave over one
|
|
1210
|
+
* of them. Which one is decided by local ownership, not by the peer: a dialed
|
|
1211
|
+
* socket is a configured peer's link and always sends. An inbound socket is
|
|
1212
|
+
* skipped only when a dialed link to the id that socket itself announced is
|
|
1213
|
+
* open, so an announcement can suppress nothing but a duplicate the
|
|
1214
|
+
* controlled link already carries — an impostor cannot drop an event for a
|
|
1215
|
+
* peer, and without a dialed link every inbound socket receives it.
|
|
1216
|
+
*
|
|
1217
|
+
* That holds once a peer's announcement has been seen. Between this
|
|
1218
|
+
* instance's dialed link opening and the peer's `hello` arriving on its
|
|
1219
|
+
* inbound socket, the inbound socket is not yet attributable, so an event
|
|
1220
|
+
* emitted in that window leaves over both sockets and reaches that peer
|
|
1221
|
+
* twice. The duplicate is transient and never a dropped event.
|
|
1222
|
+
*/
|
|
1238
1223
|
broadcast(notification) {
|
|
1239
1224
|
if (this.sockets.size === 0) return;
|
|
1240
1225
|
const frame = { type: "event", notification };
|
|
1241
1226
|
const encoded = JSON.stringify(frame);
|
|
1242
|
-
const
|
|
1227
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1243
1228
|
for (const socket of this.sockets) {
|
|
1244
1229
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
1245
1230
|
this.sockets.delete(socket);
|
|
1246
1231
|
continue;
|
|
1247
1232
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1233
|
+
const dialedPeer = this.dialedPeerOf.get(socket);
|
|
1234
|
+
if (dialedPeer !== void 0) covered.add(dialedPeer);
|
|
1235
|
+
}
|
|
1236
|
+
for (const socket of this.sockets) {
|
|
1237
|
+
if (this.dialedPeerOf.get(socket) === void 0) {
|
|
1238
|
+
const announced = this.peerOf.get(socket);
|
|
1239
|
+
if (announced !== void 0 && covered.has(announced)) continue;
|
|
1240
|
+
}
|
|
1251
1241
|
socket.send(encoded);
|
|
1252
1242
|
}
|
|
1253
1243
|
}
|
|
@@ -1378,6 +1368,9 @@ var InterconnectService = class extends Service {
|
|
|
1378
1368
|
this.ctx.logger.info(
|
|
1379
1369
|
`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
1380
1370
|
);
|
|
1371
|
+
if (remoteErrorOf(error)?.code === "session/agent-busy") {
|
|
1372
|
+
return { reason: "session-owned-by-subagent" };
|
|
1373
|
+
}
|
|
1381
1374
|
return { reason: "resume-failed" };
|
|
1382
1375
|
}
|
|
1383
1376
|
}
|
|
@@ -1484,6 +1477,17 @@ var InterconnectService = class extends Service {
|
|
|
1484
1477
|
this.attachSocket(websocket);
|
|
1485
1478
|
});
|
|
1486
1479
|
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Install frame + liveness handling on one outbound socket the dial owns for
|
|
1482
|
+
* a configured peer, recording that local ownership before anything else can
|
|
1483
|
+
* observe the socket.
|
|
1484
|
+
* @param websocket - the dialed socket.
|
|
1485
|
+
* @param peerInstanceId - the configured peer this instance dials.
|
|
1486
|
+
*/
|
|
1487
|
+
attachDialedSocket(websocket, peerInstanceId) {
|
|
1488
|
+
this.dialedPeerOf.set(websocket, peerInstanceId);
|
|
1489
|
+
this.attachSocket(websocket);
|
|
1490
|
+
}
|
|
1487
1491
|
/**
|
|
1488
1492
|
* Install frame + liveness handling on one socket and add it to the live
|
|
1489
1493
|
* pool. Used by both the server half (accepted upgrade) and the client half
|
|
@@ -1590,40 +1594,17 @@ var LinkState = class {
|
|
|
1590
1594
|
this.instanceId = instanceId;
|
|
1591
1595
|
this.peer = origin;
|
|
1592
1596
|
}
|
|
1593
|
-
|
|
1594
|
-
dialEpoch = 0;
|
|
1597
|
+
dialed;
|
|
1595
1598
|
closed = false;
|
|
1596
1599
|
retry = 0;
|
|
1597
1600
|
reconnectTimer;
|
|
1598
1601
|
warnedNoToken = false;
|
|
1599
|
-
/**
|
|
1600
|
-
dialListeners;
|
|
1601
|
-
// `peer` is mutable so `reroute` can point the link at a new origin.
|
|
1602
|
+
/** Receiver origin this link dials. */
|
|
1602
1603
|
peer;
|
|
1603
1604
|
instanceId;
|
|
1604
|
-
/** Point this link at a different origin; re-dials immediately. */
|
|
1605
|
-
reroute(origin) {
|
|
1606
|
-
if (origin === this.peer) return;
|
|
1607
|
-
this.peer = origin;
|
|
1608
|
-
this.dialEpoch += 1;
|
|
1609
|
-
if (this.reconnectTimer !== void 0) {
|
|
1610
|
-
clearTimeout(this.reconnectTimer);
|
|
1611
|
-
this.reconnectTimer = void 0;
|
|
1612
|
-
}
|
|
1613
|
-
const socket = this.socket;
|
|
1614
|
-
if (socket !== void 0) {
|
|
1615
|
-
this.removeDialListeners(socket);
|
|
1616
|
-
socket.on("error", () => {
|
|
1617
|
-
});
|
|
1618
|
-
socket.terminate();
|
|
1619
|
-
this.socket = void 0;
|
|
1620
|
-
}
|
|
1621
|
-
this.dial();
|
|
1622
|
-
}
|
|
1623
1605
|
/** Open the socket; reconnect is scheduled by the close handler. */
|
|
1624
1606
|
dial() {
|
|
1625
1607
|
if (this.closed) return;
|
|
1626
|
-
const epoch = this.dialEpoch;
|
|
1627
1608
|
let url;
|
|
1628
1609
|
try {
|
|
1629
1610
|
url = linkUrl(this.peer);
|
|
@@ -1637,11 +1618,11 @@ var LinkState = class {
|
|
|
1637
1618
|
this.warnedNoToken = true;
|
|
1638
1619
|
this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
|
|
1639
1620
|
}
|
|
1640
|
-
if (this.closed
|
|
1621
|
+
if (this.closed) return;
|
|
1641
1622
|
this.scheduleReconnect();
|
|
1642
1623
|
return;
|
|
1643
1624
|
}
|
|
1644
|
-
if (this.closed
|
|
1625
|
+
if (this.closed) return;
|
|
1645
1626
|
const socket = new WebSocket(url, {
|
|
1646
1627
|
headers: { authorization: `Bearer ${token}` },
|
|
1647
1628
|
maxPayload: MAX_LINK_FRAME_BYTES,
|
|
@@ -1652,7 +1633,6 @@ var LinkState = class {
|
|
|
1652
1633
|
// a reconnect — and ws owns the timer, so nothing leaks on teardown.
|
|
1653
1634
|
handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
|
|
1654
1635
|
});
|
|
1655
|
-
this.socket = socket;
|
|
1656
1636
|
const onOpen = () => {
|
|
1657
1637
|
this.retry = 0;
|
|
1658
1638
|
this.attachSocket(socket);
|
|
@@ -1663,49 +1643,38 @@ var LinkState = class {
|
|
|
1663
1643
|
};
|
|
1664
1644
|
const onError = () => {
|
|
1665
1645
|
};
|
|
1666
|
-
this.
|
|
1646
|
+
this.dialed = { socket, open: onOpen, close: onClose, error: onError };
|
|
1667
1647
|
socket.once("open", onOpen);
|
|
1668
1648
|
socket.once("close", onClose);
|
|
1669
1649
|
socket.on("error", onError);
|
|
1670
1650
|
}).catch(() => {
|
|
1671
|
-
if (this.closed
|
|
1651
|
+
if (this.closed) return;
|
|
1672
1652
|
this.scheduleReconnect();
|
|
1673
1653
|
});
|
|
1674
1654
|
}
|
|
1675
|
-
/** Whether this link has been closed; a closed state is never reused by `link()`. */
|
|
1676
|
-
isClosed() {
|
|
1677
|
-
return this.closed;
|
|
1678
|
-
}
|
|
1679
1655
|
/** Whether this link currently holds an open socket that can carry frames. */
|
|
1680
1656
|
writable() {
|
|
1681
|
-
|
|
1657
|
+
const dialed = this.dialed;
|
|
1658
|
+
return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
|
|
1682
1659
|
}
|
|
1683
1660
|
/** Write one frame over this link's outbound socket; false when not open. */
|
|
1684
1661
|
sendFrame(frame) {
|
|
1685
|
-
const
|
|
1686
|
-
if (
|
|
1687
|
-
socket.send(JSON.stringify(frame));
|
|
1662
|
+
const dialed = this.dialed;
|
|
1663
|
+
if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
|
|
1664
|
+
dialed.socket.send(JSON.stringify(frame));
|
|
1688
1665
|
return true;
|
|
1689
1666
|
}
|
|
1690
1667
|
close() {
|
|
1691
1668
|
this.closed = true;
|
|
1692
1669
|
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
1693
|
-
const
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
/** Detach the dial-registered handlers from a socket before terminating it. */
|
|
1702
|
-
removeDialListeners(socket) {
|
|
1703
|
-
const listeners = this.dialListeners;
|
|
1704
|
-
if (listeners === void 0) return;
|
|
1705
|
-
socket.removeListener("open", listeners.open);
|
|
1706
|
-
socket.removeListener("close", listeners.close);
|
|
1707
|
-
socket.removeListener("error", listeners.error);
|
|
1708
|
-
this.dialListeners = void 0;
|
|
1670
|
+
const dialed = this.dialed;
|
|
1671
|
+
if (dialed === void 0) return;
|
|
1672
|
+
dialed.socket.removeListener("open", dialed.open);
|
|
1673
|
+
dialed.socket.removeListener("close", dialed.close);
|
|
1674
|
+
dialed.socket.removeListener("error", dialed.error);
|
|
1675
|
+
dialed.socket.on("error", () => {
|
|
1676
|
+
});
|
|
1677
|
+
dialed.socket.terminate();
|
|
1709
1678
|
}
|
|
1710
1679
|
scheduleReconnect() {
|
|
1711
1680
|
const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
|