dsh-interconnect 0.11.12 → 0.11.13
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 +61 -98
- package/lib/interconnect/index.js +61 -98
- package/lib/interconnect/index.js.map +2 -2
- package/lib/tool-interconnect/index.js +62 -99
- package/lib/tool-interconnect/index.js.map +2 -2
- package/lib/types/interconnect/index.d.ts +27 -21
- package/lib/types/interconnect/index.d.ts.map +1 -1
- package/lib/types/interconnect/types.d.ts +6 -14
- 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.13",
|
|
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,41 @@ 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
|
+
*/
|
|
1238
1217
|
broadcast(notification) {
|
|
1239
1218
|
if (this.sockets.size === 0) return;
|
|
1240
1219
|
const frame = { type: "event", notification };
|
|
1241
1220
|
const encoded = JSON.stringify(frame);
|
|
1242
|
-
const
|
|
1221
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1243
1222
|
for (const socket of this.sockets) {
|
|
1244
1223
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
1245
1224
|
this.sockets.delete(socket);
|
|
1246
1225
|
continue;
|
|
1247
1226
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1227
|
+
const dialedPeer = this.dialedPeerOf.get(socket);
|
|
1228
|
+
if (dialedPeer !== void 0) covered.add(dialedPeer);
|
|
1229
|
+
}
|
|
1230
|
+
for (const socket of this.sockets) {
|
|
1231
|
+
if (this.dialedPeerOf.get(socket) === void 0) {
|
|
1232
|
+
const announced = this.peerOf.get(socket);
|
|
1233
|
+
if (announced !== void 0 && covered.has(announced)) continue;
|
|
1234
|
+
}
|
|
1251
1235
|
socket.send(encoded);
|
|
1252
1236
|
}
|
|
1253
1237
|
}
|
|
@@ -1378,6 +1362,9 @@ var InterconnectService = class extends Service {
|
|
|
1378
1362
|
this.ctx.logger.info(
|
|
1379
1363
|
`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
1380
1364
|
);
|
|
1365
|
+
if (remoteErrorOf(error)?.code === "session/agent-busy") {
|
|
1366
|
+
return { reason: "session-owned-by-subagent" };
|
|
1367
|
+
}
|
|
1381
1368
|
return { reason: "resume-failed" };
|
|
1382
1369
|
}
|
|
1383
1370
|
}
|
|
@@ -1484,6 +1471,17 @@ var InterconnectService = class extends Service {
|
|
|
1484
1471
|
this.attachSocket(websocket);
|
|
1485
1472
|
});
|
|
1486
1473
|
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Install frame + liveness handling on one outbound socket the dial owns for
|
|
1476
|
+
* a configured peer, recording that local ownership before anything else can
|
|
1477
|
+
* observe the socket.
|
|
1478
|
+
* @param websocket - the dialed socket.
|
|
1479
|
+
* @param peerInstanceId - the configured peer this instance dials.
|
|
1480
|
+
*/
|
|
1481
|
+
attachDialedSocket(websocket, peerInstanceId) {
|
|
1482
|
+
this.dialedPeerOf.set(websocket, peerInstanceId);
|
|
1483
|
+
this.attachSocket(websocket);
|
|
1484
|
+
}
|
|
1487
1485
|
/**
|
|
1488
1486
|
* Install frame + liveness handling on one socket and add it to the live
|
|
1489
1487
|
* pool. Used by both the server half (accepted upgrade) and the client half
|
|
@@ -1590,40 +1588,17 @@ var LinkState = class {
|
|
|
1590
1588
|
this.instanceId = instanceId;
|
|
1591
1589
|
this.peer = origin;
|
|
1592
1590
|
}
|
|
1593
|
-
|
|
1594
|
-
dialEpoch = 0;
|
|
1591
|
+
dialed;
|
|
1595
1592
|
closed = false;
|
|
1596
1593
|
retry = 0;
|
|
1597
1594
|
reconnectTimer;
|
|
1598
1595
|
warnedNoToken = false;
|
|
1599
|
-
/**
|
|
1600
|
-
dialListeners;
|
|
1601
|
-
// `peer` is mutable so `reroute` can point the link at a new origin.
|
|
1596
|
+
/** Receiver origin this link dials. */
|
|
1602
1597
|
peer;
|
|
1603
1598
|
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
1599
|
/** Open the socket; reconnect is scheduled by the close handler. */
|
|
1624
1600
|
dial() {
|
|
1625
1601
|
if (this.closed) return;
|
|
1626
|
-
const epoch = this.dialEpoch;
|
|
1627
1602
|
let url;
|
|
1628
1603
|
try {
|
|
1629
1604
|
url = linkUrl(this.peer);
|
|
@@ -1637,11 +1612,11 @@ var LinkState = class {
|
|
|
1637
1612
|
this.warnedNoToken = true;
|
|
1638
1613
|
this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
|
|
1639
1614
|
}
|
|
1640
|
-
if (this.closed
|
|
1615
|
+
if (this.closed) return;
|
|
1641
1616
|
this.scheduleReconnect();
|
|
1642
1617
|
return;
|
|
1643
1618
|
}
|
|
1644
|
-
if (this.closed
|
|
1619
|
+
if (this.closed) return;
|
|
1645
1620
|
const socket = new WebSocket(url, {
|
|
1646
1621
|
headers: { authorization: `Bearer ${token}` },
|
|
1647
1622
|
maxPayload: MAX_LINK_FRAME_BYTES,
|
|
@@ -1652,7 +1627,6 @@ var LinkState = class {
|
|
|
1652
1627
|
// a reconnect — and ws owns the timer, so nothing leaks on teardown.
|
|
1653
1628
|
handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
|
|
1654
1629
|
});
|
|
1655
|
-
this.socket = socket;
|
|
1656
1630
|
const onOpen = () => {
|
|
1657
1631
|
this.retry = 0;
|
|
1658
1632
|
this.attachSocket(socket);
|
|
@@ -1663,49 +1637,38 @@ var LinkState = class {
|
|
|
1663
1637
|
};
|
|
1664
1638
|
const onError = () => {
|
|
1665
1639
|
};
|
|
1666
|
-
this.
|
|
1640
|
+
this.dialed = { socket, open: onOpen, close: onClose, error: onError };
|
|
1667
1641
|
socket.once("open", onOpen);
|
|
1668
1642
|
socket.once("close", onClose);
|
|
1669
1643
|
socket.on("error", onError);
|
|
1670
1644
|
}).catch(() => {
|
|
1671
|
-
if (this.closed
|
|
1645
|
+
if (this.closed) return;
|
|
1672
1646
|
this.scheduleReconnect();
|
|
1673
1647
|
});
|
|
1674
1648
|
}
|
|
1675
|
-
/** Whether this link has been closed; a closed state is never reused by `link()`. */
|
|
1676
|
-
isClosed() {
|
|
1677
|
-
return this.closed;
|
|
1678
|
-
}
|
|
1679
1649
|
/** Whether this link currently holds an open socket that can carry frames. */
|
|
1680
1650
|
writable() {
|
|
1681
|
-
|
|
1651
|
+
const dialed = this.dialed;
|
|
1652
|
+
return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
|
|
1682
1653
|
}
|
|
1683
1654
|
/** Write one frame over this link's outbound socket; false when not open. */
|
|
1684
1655
|
sendFrame(frame) {
|
|
1685
|
-
const
|
|
1686
|
-
if (
|
|
1687
|
-
socket.send(JSON.stringify(frame));
|
|
1656
|
+
const dialed = this.dialed;
|
|
1657
|
+
if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
|
|
1658
|
+
dialed.socket.send(JSON.stringify(frame));
|
|
1688
1659
|
return true;
|
|
1689
1660
|
}
|
|
1690
1661
|
close() {
|
|
1691
1662
|
this.closed = true;
|
|
1692
1663
|
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;
|
|
1664
|
+
const dialed = this.dialed;
|
|
1665
|
+
if (dialed === void 0) return;
|
|
1666
|
+
dialed.socket.removeListener("open", dialed.open);
|
|
1667
|
+
dialed.socket.removeListener("close", dialed.close);
|
|
1668
|
+
dialed.socket.removeListener("error", dialed.error);
|
|
1669
|
+
dialed.socket.on("error", () => {
|
|
1670
|
+
});
|
|
1671
|
+
dialed.socket.terminate();
|
|
1709
1672
|
}
|
|
1710
1673
|
scheduleReconnect() {
|
|
1711
1674
|
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,41 @@ 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
|
+
*/
|
|
1238
1217
|
broadcast(notification) {
|
|
1239
1218
|
if (this.sockets.size === 0) return;
|
|
1240
1219
|
const frame = { type: "event", notification };
|
|
1241
1220
|
const encoded = JSON.stringify(frame);
|
|
1242
|
-
const
|
|
1221
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1243
1222
|
for (const socket of this.sockets) {
|
|
1244
1223
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
1245
1224
|
this.sockets.delete(socket);
|
|
1246
1225
|
continue;
|
|
1247
1226
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1227
|
+
const dialedPeer = this.dialedPeerOf.get(socket);
|
|
1228
|
+
if (dialedPeer !== void 0) covered.add(dialedPeer);
|
|
1229
|
+
}
|
|
1230
|
+
for (const socket of this.sockets) {
|
|
1231
|
+
if (this.dialedPeerOf.get(socket) === void 0) {
|
|
1232
|
+
const announced = this.peerOf.get(socket);
|
|
1233
|
+
if (announced !== void 0 && covered.has(announced)) continue;
|
|
1234
|
+
}
|
|
1251
1235
|
socket.send(encoded);
|
|
1252
1236
|
}
|
|
1253
1237
|
}
|
|
@@ -1378,6 +1362,9 @@ var InterconnectService = class extends Service {
|
|
|
1378
1362
|
this.ctx.logger.info(
|
|
1379
1363
|
`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
1380
1364
|
);
|
|
1365
|
+
if (remoteErrorOf(error)?.code === "session/agent-busy") {
|
|
1366
|
+
return { reason: "session-owned-by-subagent" };
|
|
1367
|
+
}
|
|
1381
1368
|
return { reason: "resume-failed" };
|
|
1382
1369
|
}
|
|
1383
1370
|
}
|
|
@@ -1484,6 +1471,17 @@ var InterconnectService = class extends Service {
|
|
|
1484
1471
|
this.attachSocket(websocket);
|
|
1485
1472
|
});
|
|
1486
1473
|
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Install frame + liveness handling on one outbound socket the dial owns for
|
|
1476
|
+
* a configured peer, recording that local ownership before anything else can
|
|
1477
|
+
* observe the socket.
|
|
1478
|
+
* @param websocket - the dialed socket.
|
|
1479
|
+
* @param peerInstanceId - the configured peer this instance dials.
|
|
1480
|
+
*/
|
|
1481
|
+
attachDialedSocket(websocket, peerInstanceId) {
|
|
1482
|
+
this.dialedPeerOf.set(websocket, peerInstanceId);
|
|
1483
|
+
this.attachSocket(websocket);
|
|
1484
|
+
}
|
|
1487
1485
|
/**
|
|
1488
1486
|
* Install frame + liveness handling on one socket and add it to the live
|
|
1489
1487
|
* pool. Used by both the server half (accepted upgrade) and the client half
|
|
@@ -1590,40 +1588,17 @@ var LinkState = class {
|
|
|
1590
1588
|
this.instanceId = instanceId;
|
|
1591
1589
|
this.peer = origin;
|
|
1592
1590
|
}
|
|
1593
|
-
|
|
1594
|
-
dialEpoch = 0;
|
|
1591
|
+
dialed;
|
|
1595
1592
|
closed = false;
|
|
1596
1593
|
retry = 0;
|
|
1597
1594
|
reconnectTimer;
|
|
1598
1595
|
warnedNoToken = false;
|
|
1599
|
-
/**
|
|
1600
|
-
dialListeners;
|
|
1601
|
-
// `peer` is mutable so `reroute` can point the link at a new origin.
|
|
1596
|
+
/** Receiver origin this link dials. */
|
|
1602
1597
|
peer;
|
|
1603
1598
|
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
1599
|
/** Open the socket; reconnect is scheduled by the close handler. */
|
|
1624
1600
|
dial() {
|
|
1625
1601
|
if (this.closed) return;
|
|
1626
|
-
const epoch = this.dialEpoch;
|
|
1627
1602
|
let url;
|
|
1628
1603
|
try {
|
|
1629
1604
|
url = linkUrl(this.peer);
|
|
@@ -1637,11 +1612,11 @@ var LinkState = class {
|
|
|
1637
1612
|
this.warnedNoToken = true;
|
|
1638
1613
|
this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
|
|
1639
1614
|
}
|
|
1640
|
-
if (this.closed
|
|
1615
|
+
if (this.closed) return;
|
|
1641
1616
|
this.scheduleReconnect();
|
|
1642
1617
|
return;
|
|
1643
1618
|
}
|
|
1644
|
-
if (this.closed
|
|
1619
|
+
if (this.closed) return;
|
|
1645
1620
|
const socket = new WebSocket(url, {
|
|
1646
1621
|
headers: { authorization: `Bearer ${token}` },
|
|
1647
1622
|
maxPayload: MAX_LINK_FRAME_BYTES,
|
|
@@ -1652,7 +1627,6 @@ var LinkState = class {
|
|
|
1652
1627
|
// a reconnect — and ws owns the timer, so nothing leaks on teardown.
|
|
1653
1628
|
handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
|
|
1654
1629
|
});
|
|
1655
|
-
this.socket = socket;
|
|
1656
1630
|
const onOpen = () => {
|
|
1657
1631
|
this.retry = 0;
|
|
1658
1632
|
this.attachSocket(socket);
|
|
@@ -1663,49 +1637,38 @@ var LinkState = class {
|
|
|
1663
1637
|
};
|
|
1664
1638
|
const onError = () => {
|
|
1665
1639
|
};
|
|
1666
|
-
this.
|
|
1640
|
+
this.dialed = { socket, open: onOpen, close: onClose, error: onError };
|
|
1667
1641
|
socket.once("open", onOpen);
|
|
1668
1642
|
socket.once("close", onClose);
|
|
1669
1643
|
socket.on("error", onError);
|
|
1670
1644
|
}).catch(() => {
|
|
1671
|
-
if (this.closed
|
|
1645
|
+
if (this.closed) return;
|
|
1672
1646
|
this.scheduleReconnect();
|
|
1673
1647
|
});
|
|
1674
1648
|
}
|
|
1675
|
-
/** Whether this link has been closed; a closed state is never reused by `link()`. */
|
|
1676
|
-
isClosed() {
|
|
1677
|
-
return this.closed;
|
|
1678
|
-
}
|
|
1679
1649
|
/** Whether this link currently holds an open socket that can carry frames. */
|
|
1680
1650
|
writable() {
|
|
1681
|
-
|
|
1651
|
+
const dialed = this.dialed;
|
|
1652
|
+
return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
|
|
1682
1653
|
}
|
|
1683
1654
|
/** Write one frame over this link's outbound socket; false when not open. */
|
|
1684
1655
|
sendFrame(frame) {
|
|
1685
|
-
const
|
|
1686
|
-
if (
|
|
1687
|
-
socket.send(JSON.stringify(frame));
|
|
1656
|
+
const dialed = this.dialed;
|
|
1657
|
+
if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
|
|
1658
|
+
dialed.socket.send(JSON.stringify(frame));
|
|
1688
1659
|
return true;
|
|
1689
1660
|
}
|
|
1690
1661
|
close() {
|
|
1691
1662
|
this.closed = true;
|
|
1692
1663
|
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;
|
|
1664
|
+
const dialed = this.dialed;
|
|
1665
|
+
if (dialed === void 0) return;
|
|
1666
|
+
dialed.socket.removeListener("open", dialed.open);
|
|
1667
|
+
dialed.socket.removeListener("close", dialed.close);
|
|
1668
|
+
dialed.socket.removeListener("error", dialed.error);
|
|
1669
|
+
dialed.socket.on("error", () => {
|
|
1670
|
+
});
|
|
1671
|
+
dialed.socket.terminate();
|
|
1709
1672
|
}
|
|
1710
1673
|
scheduleReconnect() {
|
|
1711
1674
|
const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
|