negotium 0.19.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-helpers.js +1146 -212
- package/dist/agent-helpers.js.map +27 -19
- package/dist/background-bash.js.map +1 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
- package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
- package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
- package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
- package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
- package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
- package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
- package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
- package/dist/hosted-agent.js +290 -12
- package/dist/hosted-agent.js.map +10 -7
- package/dist/main.js +4599 -2724
- package/dist/main.js.map +49 -38
- package/dist/mcp-factories.js +1166 -162
- package/dist/mcp-factories.js.map +27 -19
- package/dist/media.js.map +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
- package/dist/runtime/src/agents/public-helpers.ts +5 -0
- package/dist/runtime/src/agents/self-config-core.ts +28 -58
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
- package/dist/runtime/src/index.ts +75 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
- package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
- package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
- package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
- package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
- package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
- package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
- package/dist/runtime/src/mcp-runtime-host.ts +2 -1
- package/dist/runtime/src/node-host.ts +26 -0
- package/dist/runtime/src/platform/mcp-config.ts +56 -2
- package/dist/runtime/src/query/active-rooms.ts +6 -0
- package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
- package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
- package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
- package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
- package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
- package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
- package/dist/runtime/src/runtime/turn-runner.ts +157 -33
- package/dist/runtime/src/runtime-gateway.ts +20 -0
- package/dist/runtime/src/storage/api-topics.ts +22 -3
- package/dist/runtime/src/storage/remote-session.ts +526 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
- package/dist/runtime/src/topics/derive.ts +3 -0
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +42 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-gateway.js.map +2 -2
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js +16 -4
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
- package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
- package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
- package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
- package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
- package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
- package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
- package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
- package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
- package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
- package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
- package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
- package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
- package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +40 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
- /package/dist/{chunk-aqqmzmft.js.map → chunk-nm59q3em.js.map} +0 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the hub delivers into this node for remote session-comm
|
|
3
|
+
* (`POST /topics/:nodeTopicId/session-comm/inbox`): a tell, an ask (with the
|
|
4
|
+
* hub's reply route), an abort, or the answer to an ask this node's room
|
|
5
|
+
* raised. Parsing and the idempotent hand-off to the durable session inbox
|
|
6
|
+
* live here so the gateway route in `control.ts` only does transport.
|
|
7
|
+
*/
|
|
8
|
+
import { MAX_PEER_MESSAGE_LENGTH } from "#mcp/session-comm/limits";
|
|
9
|
+
import { type HubRemoteReplyRoute, parseHubRemoteReplyRoute } from "#mcp/session-comm/peer-forward";
|
|
10
|
+
import { MAX_TELL_DEPTH } from "#platform/config";
|
|
11
|
+
import { logger } from "#platform/logger";
|
|
12
|
+
import { db } from "#storage/forum-db";
|
|
13
|
+
import {
|
|
14
|
+
claimRemoteSessionInbox,
|
|
15
|
+
completeRemoteSessionInboxClaim,
|
|
16
|
+
deleteRemoteSessionAsk,
|
|
17
|
+
getRemoteSessionAsk,
|
|
18
|
+
listExpiredRemoteSessionInboxClaims,
|
|
19
|
+
type RemoteSessionAskRecord,
|
|
20
|
+
releaseRemoteSessionInboxClaim,
|
|
21
|
+
remoteSessionPayloadHash,
|
|
22
|
+
} from "#storage/remote-session";
|
|
23
|
+
import { enqueueSessionInbox } from "#storage/session-inbox";
|
|
24
|
+
import type { TopicDto } from "#types/api";
|
|
25
|
+
|
|
26
|
+
export const MAX_REMOTE_SESSION_REQUEST_ID_LENGTH = 200;
|
|
27
|
+
export const MAX_REMOTE_SESSION_LABEL_LENGTH = 300;
|
|
28
|
+
|
|
29
|
+
export interface RemoteSessionInboxFrom {
|
|
30
|
+
/** "<originNode>/<originTitle>", shown to the target as the sender. */
|
|
31
|
+
label: string;
|
|
32
|
+
/** The hub's own id of the origin room, for the hub's audit trail; opaque here. */
|
|
33
|
+
hubTopicId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RemoteSessionInboxDelivery =
|
|
37
|
+
| {
|
|
38
|
+
kind: "tell";
|
|
39
|
+
requestId: string;
|
|
40
|
+
from: RemoteSessionInboxFrom;
|
|
41
|
+
message: string;
|
|
42
|
+
depth: number;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
kind: "ask";
|
|
46
|
+
requestId: string;
|
|
47
|
+
from: RemoteSessionInboxFrom;
|
|
48
|
+
message: string;
|
|
49
|
+
fromDepth: number;
|
|
50
|
+
remoteReply: HubRemoteReplyRoute;
|
|
51
|
+
}
|
|
52
|
+
| { kind: "abort"; requestId: string }
|
|
53
|
+
| {
|
|
54
|
+
kind: "ask-reply";
|
|
55
|
+
requestId: string;
|
|
56
|
+
fromLabel: string;
|
|
57
|
+
replyKind: "reply" | "error";
|
|
58
|
+
replyText: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A malformed delivery, answered with its status (400 or 413). */
|
|
62
|
+
export class RemoteSessionInboxError extends Error {
|
|
63
|
+
constructor(
|
|
64
|
+
readonly status: number,
|
|
65
|
+
message: string,
|
|
66
|
+
) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "RemoteSessionInboxError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function text(value: unknown, name: string, max?: number): string {
|
|
73
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
74
|
+
throw new RemoteSessionInboxError(400, `${name} is required`);
|
|
75
|
+
}
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (max !== undefined && trimmed.length > max) {
|
|
78
|
+
throw new RemoteSessionInboxError(400, `${name} is longer than ${max} characters`);
|
|
79
|
+
}
|
|
80
|
+
return trimmed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function message(value: unknown): string {
|
|
84
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
85
|
+
throw new RemoteSessionInboxError(400, "message is required");
|
|
86
|
+
}
|
|
87
|
+
if (value.length > MAX_PEER_MESSAGE_LENGTH) {
|
|
88
|
+
throw new RemoteSessionInboxError(
|
|
89
|
+
413,
|
|
90
|
+
`message is ${value.length} characters; at most ${MAX_PEER_MESSAGE_LENGTH} are allowed`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function depth(value: unknown, name: string, max: number): number {
|
|
97
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
98
|
+
throw new RemoteSessionInboxError(400, `${name} must be a non-negative integer`);
|
|
99
|
+
}
|
|
100
|
+
if ((value as number) > max) {
|
|
101
|
+
throw new RemoteSessionInboxError(400, `${name} exceeds the tell depth limit (${max})`);
|
|
102
|
+
}
|
|
103
|
+
return value as number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function from(value: unknown): RemoteSessionInboxFrom {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
108
|
+
throw new RemoteSessionInboxError(400, "from must be { label, hubTopicId? }");
|
|
109
|
+
}
|
|
110
|
+
const record = value as Record<string, unknown>;
|
|
111
|
+
const label = text(record.label, "from.label", MAX_REMOTE_SESSION_LABEL_LENGTH);
|
|
112
|
+
if (record.hubTopicId !== undefined && typeof record.hubTopicId !== "string") {
|
|
113
|
+
throw new RemoteSessionInboxError(400, "from.hubTopicId must be a string");
|
|
114
|
+
}
|
|
115
|
+
return { label, ...(record.hubTopicId ? { hubTopicId: record.hubTopicId } : {}) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate a delivery body; throws {@link RemoteSessionInboxError}. */
|
|
119
|
+
export function parseRemoteSessionInboxDelivery(
|
|
120
|
+
body: Record<string, unknown>,
|
|
121
|
+
): RemoteSessionInboxDelivery {
|
|
122
|
+
const kind = text(body.kind, "kind");
|
|
123
|
+
const requestId = text(body.requestId, "requestId", MAX_REMOTE_SESSION_REQUEST_ID_LENGTH);
|
|
124
|
+
switch (kind) {
|
|
125
|
+
case "tell":
|
|
126
|
+
return {
|
|
127
|
+
kind,
|
|
128
|
+
requestId,
|
|
129
|
+
from: from(body.from),
|
|
130
|
+
message: message(body.message),
|
|
131
|
+
// The hub sends the depth the target runs at (caller + 1), already
|
|
132
|
+
// checked there; checked again here so a node is never made to run
|
|
133
|
+
// deeper than its own limit.
|
|
134
|
+
depth: depth(body.depth, "depth", MAX_TELL_DEPTH),
|
|
135
|
+
};
|
|
136
|
+
case "ask": {
|
|
137
|
+
const remoteReply = parseHubRemoteReplyRoute(body.remoteReply);
|
|
138
|
+
if (!remoteReply) {
|
|
139
|
+
throw new RemoteSessionInboxError(
|
|
140
|
+
400,
|
|
141
|
+
"remoteReply must be { via: 'hub', hubUrl, token, nodeName, topicId, requestId }",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (remoteReply.requestId !== requestId) {
|
|
145
|
+
throw new RemoteSessionInboxError(400, "remoteReply.requestId must equal requestId");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
kind,
|
|
149
|
+
requestId,
|
|
150
|
+
from: from(body.from),
|
|
151
|
+
message: message(body.message),
|
|
152
|
+
// The caller's own depth; the fork answering it runs one deeper.
|
|
153
|
+
fromDepth: depth(body.fromDepth, "fromDepth", MAX_TELL_DEPTH),
|
|
154
|
+
remoteReply,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case "abort":
|
|
158
|
+
return { kind, requestId };
|
|
159
|
+
case "ask-reply": {
|
|
160
|
+
const replyKind = text(body.replyKind, "replyKind");
|
|
161
|
+
if (replyKind !== "reply" && replyKind !== "error") {
|
|
162
|
+
throw new RemoteSessionInboxError(400, "replyKind must be 'reply' or 'error'");
|
|
163
|
+
}
|
|
164
|
+
if (typeof body.replyText !== "string") {
|
|
165
|
+
throw new RemoteSessionInboxError(400, "replyText is required");
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
kind,
|
|
169
|
+
requestId,
|
|
170
|
+
fromLabel: text(body.fromLabel, "fromLabel", MAX_REMOTE_SESSION_LABEL_LENGTH),
|
|
171
|
+
replyKind,
|
|
172
|
+
replyText: body.replyText,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
throw new RemoteSessionInboxError(400, "kind must be one of tell, ask, abort, ask-reply");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type RemoteSessionInboxOutcome =
|
|
181
|
+
| { ok: true; replayed: boolean }
|
|
182
|
+
| { ok: false; status: number; error: string; code?: string };
|
|
183
|
+
|
|
184
|
+
/** Answer for a duplicate that meets a live `processing` claim: come back later. */
|
|
185
|
+
export const REMOTE_SESSION_INBOX_IN_PROGRESS_CODE = "in_progress";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One delivery at a time per `requestId` inside this process. The durable
|
|
189
|
+
* lease guards across processes; this guards the async window between the
|
|
190
|
+
* claim and its completion within one, so two identical ask-replies arriving
|
|
191
|
+
* together run strictly one after the other (the second then sees either a
|
|
192
|
+
* `completed` claim, a released one, or the ask consumed).
|
|
193
|
+
*/
|
|
194
|
+
const inboxLocks = new Map<string, Promise<unknown>>();
|
|
195
|
+
|
|
196
|
+
async function withRequestLock<T>(requestId: string, run: () => Promise<T>): Promise<T> {
|
|
197
|
+
const previous = inboxLocks.get(requestId) ?? Promise.resolve();
|
|
198
|
+
const current = previous.then(run, run);
|
|
199
|
+
const settled = current.then(
|
|
200
|
+
() => undefined,
|
|
201
|
+
() => undefined,
|
|
202
|
+
);
|
|
203
|
+
inboxLocks.set(requestId, settled);
|
|
204
|
+
try {
|
|
205
|
+
return await current;
|
|
206
|
+
} finally {
|
|
207
|
+
if (inboxLocks.get(requestId) === settled) inboxLocks.delete(requestId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Hand one hub delivery to the durable session inbox exactly once.
|
|
213
|
+
*
|
|
214
|
+
* The claim and the enqueue share a transaction: a retried delivery with the
|
|
215
|
+
* same payload is acknowledged as a replay without a second entry, the same
|
|
216
|
+
* `requestId` with a different payload is a conflict, and a failed enqueue
|
|
217
|
+
* leaves no claim behind so the hub's retry is accepted. `ask-reply` is the
|
|
218
|
+
* caller side: the row `recordRemoteSessionAsk` wrote when the ask went out
|
|
219
|
+
* says which room (and thread) the answer belongs in.
|
|
220
|
+
*/
|
|
221
|
+
export async function deliverRemoteSessionInbox(args: {
|
|
222
|
+
topic: Pick<TopicDto, "id" | "title" | "agent">;
|
|
223
|
+
/** Execution principal the inbox entry is queued under (a participant). */
|
|
224
|
+
userId: string;
|
|
225
|
+
delivery: RemoteSessionInboxDelivery;
|
|
226
|
+
}): Promise<RemoteSessionInboxOutcome> {
|
|
227
|
+
const { topic, userId, delivery } = args;
|
|
228
|
+
const payloadHash = remoteSessionPayloadHash(delivery);
|
|
229
|
+
const timestamp = new Date().toISOString();
|
|
230
|
+
|
|
231
|
+
if (delivery.kind === "tell" || delivery.kind === "ask") {
|
|
232
|
+
// The hub already judged "has AI" from its mirror; the node's own row is
|
|
233
|
+
// final (the two can disagree for a moment after a config change).
|
|
234
|
+
if (!topic.agent) {
|
|
235
|
+
return { ok: false, status: 409, error: `Session "${topic.title}" has no AI invited.` };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (delivery.kind === "ask-reply") {
|
|
240
|
+
return withRequestLock(delivery.requestId, () =>
|
|
241
|
+
deliverAskReply({ topic, delivery, payloadHash }),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const entry =
|
|
246
|
+
delivery.kind === "tell"
|
|
247
|
+
? {
|
|
248
|
+
type: "tell" as const,
|
|
249
|
+
requestId: delivery.requestId,
|
|
250
|
+
from: delivery.from.label,
|
|
251
|
+
fromTitle: delivery.from.label,
|
|
252
|
+
message: delivery.message,
|
|
253
|
+
depth: delivery.depth,
|
|
254
|
+
timestamp,
|
|
255
|
+
}
|
|
256
|
+
: delivery.kind === "ask"
|
|
257
|
+
? {
|
|
258
|
+
type: "ask" as const,
|
|
259
|
+
requestId: delivery.requestId,
|
|
260
|
+
from: delivery.from.label,
|
|
261
|
+
fromTitle: delivery.from.label,
|
|
262
|
+
message: delivery.message,
|
|
263
|
+
fromDepth: delivery.fromDepth,
|
|
264
|
+
remoteReply: delivery.remoteReply,
|
|
265
|
+
timestamp,
|
|
266
|
+
}
|
|
267
|
+
: { type: "abort" as const, timestamp };
|
|
268
|
+
|
|
269
|
+
const outcome = db.transaction((): RemoteSessionInboxOutcome => {
|
|
270
|
+
const claim = claimRemoteSessionInbox({
|
|
271
|
+
requestId: delivery.requestId,
|
|
272
|
+
kind: delivery.kind,
|
|
273
|
+
topicId: topic.id,
|
|
274
|
+
payloadHash,
|
|
275
|
+
});
|
|
276
|
+
if (claim === "replay") return { ok: true, replayed: true };
|
|
277
|
+
if (claim === "conflict") {
|
|
278
|
+
return { ok: false, status: 409, error: "requestId is already bound to another delivery" };
|
|
279
|
+
}
|
|
280
|
+
if (claim === "in_progress") {
|
|
281
|
+
// Cannot happen for a synchronous claim+enqueue, but the state machine
|
|
282
|
+
// is uniform: a live lease is never answered as a replay.
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
status: 409,
|
|
286
|
+
error: "this delivery is still being processed; retry shortly",
|
|
287
|
+
code: REMOTE_SESSION_INBOX_IN_PROGRESS_CODE,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
enqueueSessionInbox({ userId, topicId: topic.id, entry });
|
|
291
|
+
// Same transaction as the enqueue: the claim is `completed` exactly when
|
|
292
|
+
// the entry is durable, never before.
|
|
293
|
+
completeRemoteSessionInboxClaim(delivery.requestId);
|
|
294
|
+
return { ok: true, replayed: false };
|
|
295
|
+
})();
|
|
296
|
+
if (outcome.ok && !outcome.replayed) {
|
|
297
|
+
logger.info(
|
|
298
|
+
{ topicId: topic.id, kind: delivery.kind, requestId: delivery.requestId },
|
|
299
|
+
"session-comm: remote delivery queued",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return outcome;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type AskReplyDelivery = Extract<RemoteSessionInboxDelivery, { kind: "ask-reply" }>;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The caller side of a remote ask, as one durable state machine:
|
|
309
|
+
*
|
|
310
|
+
* claim `processing` (payload persisted) → deliver into the caller room →
|
|
311
|
+
* [same transaction] record written + ask row consumed + claim `completed`.
|
|
312
|
+
*
|
|
313
|
+
* Nothing is deleted before the answer is durably in the room, so a crash at
|
|
314
|
+
* any step leaves either a `processing` claim (re-run by
|
|
315
|
+
* {@link recoverRemoteSessionInbox} once its lease expires, the ask row still
|
|
316
|
+
* there) or a `completed` one (a replay). A duplicate that meets a live lease
|
|
317
|
+
* is told `in_progress` (409) — never a false replay — and a delivery that
|
|
318
|
+
* fails releases the claim so the hub's retry runs it again.
|
|
319
|
+
*/
|
|
320
|
+
async function deliverAskReply(args: {
|
|
321
|
+
topic: Pick<TopicDto, "id" | "title" | "agent">;
|
|
322
|
+
delivery: AskReplyDelivery;
|
|
323
|
+
payloadHash: string;
|
|
324
|
+
now?: number;
|
|
325
|
+
}): Promise<RemoteSessionInboxOutcome> {
|
|
326
|
+
const { topic, delivery, payloadHash } = args;
|
|
327
|
+
const claim = claimRemoteSessionInbox({
|
|
328
|
+
requestId: delivery.requestId,
|
|
329
|
+
kind: delivery.kind,
|
|
330
|
+
topicId: topic.id,
|
|
331
|
+
payloadHash,
|
|
332
|
+
payload: delivery,
|
|
333
|
+
now: args.now,
|
|
334
|
+
});
|
|
335
|
+
if (claim === "replay") return { ok: true, replayed: true };
|
|
336
|
+
if (claim === "conflict") {
|
|
337
|
+
return { ok: false, status: 409, error: "requestId is already bound to another delivery" };
|
|
338
|
+
}
|
|
339
|
+
if (claim === "in_progress") {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
status: 409,
|
|
343
|
+
error: "this reply is still being delivered; retry shortly",
|
|
344
|
+
code: REMOTE_SESSION_INBOX_IN_PROGRESS_CODE,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const ask = getRemoteSessionAsk(delivery.requestId);
|
|
348
|
+
if (!ask || ask.callerTopicId !== topic.id) {
|
|
349
|
+
releaseRemoteSessionInboxClaim(delivery.requestId);
|
|
350
|
+
return { ok: false, status: 404, error: "no pending remote ask with this requestId" };
|
|
351
|
+
}
|
|
352
|
+
return runAskReplyDelivery(ask, delivery);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
type AskReplyDeliverer = typeof import("#runtime/turn-runner").deliverAskCallbackToCaller;
|
|
356
|
+
|
|
357
|
+
let askReplyDeliverer: AskReplyDeliverer | null = null;
|
|
358
|
+
|
|
359
|
+
/** Test seam: replace the caller-room injection (to fail it, or to observe it). */
|
|
360
|
+
export function setRemoteSessionAskReplyDeliverer(next: AskReplyDeliverer | null): void {
|
|
361
|
+
askReplyDeliverer = next;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Deliver and, in the same transaction as the room record, consume + complete. */
|
|
365
|
+
async function runAskReplyDelivery(
|
|
366
|
+
ask: RemoteSessionAskRecord,
|
|
367
|
+
delivery: AskReplyDelivery,
|
|
368
|
+
): Promise<RemoteSessionInboxOutcome> {
|
|
369
|
+
const deliverAskCallbackToCaller =
|
|
370
|
+
askReplyDeliverer ?? (await import("#runtime/turn-runner")).deliverAskCallbackToCaller;
|
|
371
|
+
let delivered = false;
|
|
372
|
+
let recorded = false;
|
|
373
|
+
const onRecorded = () => {
|
|
374
|
+
deleteRemoteSessionAsk(ask.requestId);
|
|
375
|
+
completeRemoteSessionInboxClaim(ask.requestId);
|
|
376
|
+
recorded = true;
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
delivered = await deliverAskCallbackToCaller(
|
|
380
|
+
{
|
|
381
|
+
requestId: ask.requestId,
|
|
382
|
+
callerTopicId: ask.callerTopicId,
|
|
383
|
+
...(ask.callerThreadRootId ? { callerThreadRootId: ask.callerThreadRootId } : {}),
|
|
384
|
+
callerUserId: ask.userId,
|
|
385
|
+
pendingAsk: {
|
|
386
|
+
userId: ask.userId,
|
|
387
|
+
from: ask.fromKey,
|
|
388
|
+
to: ask.toKey,
|
|
389
|
+
requestId: ask.requestId,
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
delivery.fromLabel,
|
|
393
|
+
delivery.replyText,
|
|
394
|
+
delivery.replyKind,
|
|
395
|
+
{ onRecorded },
|
|
396
|
+
);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
logger.warn(
|
|
399
|
+
{ err, requestId: ask.requestId, callerTopicId: ask.callerTopicId },
|
|
400
|
+
"session-comm: remote ask reply delivery threw",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (!delivered || !recorded) {
|
|
404
|
+
// Nothing durable happened in the caller room: hand the claim back so the
|
|
405
|
+
// hub's retry (or the recovery pass) runs the delivery again.
|
|
406
|
+
releaseRemoteSessionInboxClaim(ask.requestId);
|
|
407
|
+
return { ok: false, status: 500, error: "reply could not be delivered to the caller" };
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, replayed: false };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Re-run deliveries a previous process (or a hung one) left `processing`
|
|
414
|
+
* past their lease. Called from the periodic maintenance pass, and at startup
|
|
415
|
+
* with `includeLive` (a fresh process knows no lease is really held). An ask-reply whose ask row is gone can only be a completed delivery
|
|
416
|
+
* whose completion did not persist (impossible by construction, but cheap to
|
|
417
|
+
* tolerate): it is completed. Claims of the other kinds complete in the same
|
|
418
|
+
* transaction as their enqueue, so an expired `processing` one is a claim
|
|
419
|
+
* whose enqueue never happened — released, so the hub's retry is accepted.
|
|
420
|
+
*/
|
|
421
|
+
export async function recoverRemoteSessionInbox(
|
|
422
|
+
now: number = Date.now(),
|
|
423
|
+
options: { includeLive?: boolean } = {},
|
|
424
|
+
): Promise<number> {
|
|
425
|
+
let recovered = 0;
|
|
426
|
+
for (const claim of listExpiredRemoteSessionInboxClaims(now, options)) {
|
|
427
|
+
if (claim.kind !== "ask-reply") {
|
|
428
|
+
releaseRemoteSessionInboxClaim(claim.requestId);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
let delivery: AskReplyDelivery | null = null;
|
|
432
|
+
try {
|
|
433
|
+
const parsed = parseRemoteSessionInboxDelivery(
|
|
434
|
+
(claim.payload ?? {}) as Record<string, unknown>,
|
|
435
|
+
);
|
|
436
|
+
if (parsed.kind === "ask-reply") delivery = parsed;
|
|
437
|
+
} catch {
|
|
438
|
+
delivery = null;
|
|
439
|
+
}
|
|
440
|
+
if (!delivery) {
|
|
441
|
+
logger.warn(
|
|
442
|
+
{ requestId: claim.requestId },
|
|
443
|
+
"session-comm: interrupted remote ask reply has no readable payload; releasing",
|
|
444
|
+
);
|
|
445
|
+
releaseRemoteSessionInboxClaim(claim.requestId);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const outcome = await withRequestLock(claim.requestId, async () => {
|
|
449
|
+
// Re-take the lease under the lock; a concurrent hub retry may have
|
|
450
|
+
// beaten us to it.
|
|
451
|
+
const retaken = claimRemoteSessionInbox({
|
|
452
|
+
requestId: claim.requestId,
|
|
453
|
+
kind: claim.kind,
|
|
454
|
+
topicId: claim.topicId,
|
|
455
|
+
payloadHash: claim.payloadHash,
|
|
456
|
+
payload: delivery,
|
|
457
|
+
now,
|
|
458
|
+
force: options.includeLive === true,
|
|
459
|
+
});
|
|
460
|
+
if (retaken !== "claimed") return retaken;
|
|
461
|
+
const ask = getRemoteSessionAsk(claim.requestId);
|
|
462
|
+
if (!ask) {
|
|
463
|
+
completeRemoteSessionInboxClaim(claim.requestId);
|
|
464
|
+
return "completed-orphan";
|
|
465
|
+
}
|
|
466
|
+
const result = await runAskReplyDelivery(ask, delivery);
|
|
467
|
+
return result.ok ? "delivered" : "failed";
|
|
468
|
+
});
|
|
469
|
+
if (outcome === "delivered") recovered += 1;
|
|
470
|
+
logger.info(
|
|
471
|
+
{ requestId: claim.requestId, outcome },
|
|
472
|
+
"session-comm: recovered interrupted remote ask reply",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return recovered;
|
|
476
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable delivery of remote ask answers to the hub.
|
|
3
|
+
*
|
|
4
|
+
* The target side of a hub-routed `ask_session` answers by posting to the
|
|
5
|
+
* hub with the one-shot `rsr1` reply token the hub put in the inbox entry.
|
|
6
|
+
* The hub may be briefly unreachable (relay hiccup, restart), so the answer
|
|
7
|
+
* is written to `remote_session_reply_outbox` first and posted from there:
|
|
8
|
+
* immediately, then on a 5/10/20/30 s (cap) jittered backoff polled every
|
|
9
|
+
* `REMOTE_SESSION_REPLY_RETRY_MS`, until the hub settles it or the caller's
|
|
10
|
+
* pending-ask TTL (15 min) has passed — after which the caller has already
|
|
11
|
+
* timed out and the answer is dropped with a log line.
|
|
12
|
+
*
|
|
13
|
+
* The same worker is the node's periodic remote-session maintenance: it also
|
|
14
|
+
* purges expired inbox claims and stale caller asks, and re-runs inbox
|
|
15
|
+
* deliveries an earlier process left mid-flight.
|
|
16
|
+
*/
|
|
17
|
+
import { hubRemoteReply } from "#mcp/session-comm/hub-remote-session";
|
|
18
|
+
import { logger } from "#platform/logger";
|
|
19
|
+
import { deleteHubRemoteAskCallbackRow, purgeStaleAsks } from "#runtime/ask-callbacks";
|
|
20
|
+
import { recoverRemoteSessionInbox } from "#runtime/remote-session-inbox";
|
|
21
|
+
import { db } from "#storage/forum-db";
|
|
22
|
+
import {
|
|
23
|
+
deferRemoteSessionReplyOutbox,
|
|
24
|
+
deleteRemoteSessionReplyOutbox,
|
|
25
|
+
listRemoteSessionReplyOutbox,
|
|
26
|
+
purgeExpiredRemoteSessionReplyOutbox,
|
|
27
|
+
purgeRemoteSessionInboxClaims,
|
|
28
|
+
purgeStaleRemoteSessionAsks,
|
|
29
|
+
REMOTE_SESSION_REPLY_RETRY_MS,
|
|
30
|
+
upsertRemoteSessionReplyOutbox,
|
|
31
|
+
} from "#storage/remote-session";
|
|
32
|
+
|
|
33
|
+
let retryRandom: () => number = Math.random;
|
|
34
|
+
|
|
35
|
+
/** Test seam: make the backoff jitter deterministic. */
|
|
36
|
+
export function setRemoteSessionRetryRandom(random: (() => number) | null): void {
|
|
37
|
+
retryRandom = random ?? Math.random;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HubRemoteReplyRoute {
|
|
41
|
+
via: "hub";
|
|
42
|
+
hubUrl: string;
|
|
43
|
+
/** One-shot `rsr1.` reply token, verified by the hub only. */
|
|
44
|
+
token: string;
|
|
45
|
+
/** Origin node's name, for the "[Reply from node/room]" label on the caller side. */
|
|
46
|
+
nodeName: string;
|
|
47
|
+
/** Caller's node topic id, as the hub recorded it; informational on this side. */
|
|
48
|
+
topicId: string;
|
|
49
|
+
requestId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Queue the answer and try to post it right away. Always durable, hence
|
|
54
|
+
* always true. The outbox insert and the removal of the durable ask-callback
|
|
55
|
+
* row (`remote_ask_callbacks`, kept by `resolveAskCallback` for hub routes)
|
|
56
|
+
* are one transaction: at no point is there neither a callback nor an outbox
|
|
57
|
+
* row, so a crash anywhere leaves either the startup "interrupted" error or
|
|
58
|
+
* the real answer to be posted — never silence.
|
|
59
|
+
*/
|
|
60
|
+
export async function deliverHubRemoteReply(
|
|
61
|
+
route: HubRemoteReplyRoute,
|
|
62
|
+
fromLabel: string,
|
|
63
|
+
replyText: string,
|
|
64
|
+
kind: "reply" | "error",
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
db.transaction(() => {
|
|
67
|
+
upsertRemoteSessionReplyOutbox({
|
|
68
|
+
requestId: route.requestId,
|
|
69
|
+
hubUrl: route.hubUrl,
|
|
70
|
+
token: route.token,
|
|
71
|
+
kind,
|
|
72
|
+
replyText,
|
|
73
|
+
fromLabel,
|
|
74
|
+
});
|
|
75
|
+
deleteHubRemoteAskCallbackRow(route.requestId);
|
|
76
|
+
})();
|
|
77
|
+
// Network only after commit.
|
|
78
|
+
await flushRemoteSessionReplyOutbox();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let inFlight: Promise<void> | null = null;
|
|
83
|
+
|
|
84
|
+
/** Post every due answer once. Concurrent callers share one pass. */
|
|
85
|
+
export function flushRemoteSessionReplyOutbox(now: number = Date.now()): Promise<void> {
|
|
86
|
+
if (inFlight) return inFlight;
|
|
87
|
+
inFlight = (async () => {
|
|
88
|
+
for (const expired of purgeExpiredRemoteSessionReplyOutbox(now)) {
|
|
89
|
+
logger.warn(
|
|
90
|
+
{ requestId: expired.requestId, attempts: expired.attempts, lastError: expired.lastError },
|
|
91
|
+
"session-comm: remote ask reply expired before the hub accepted it",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
for (const entry of listRemoteSessionReplyOutbox({ now })) {
|
|
95
|
+
const result = await hubRemoteReply({
|
|
96
|
+
hubUrl: entry.hubUrl,
|
|
97
|
+
token: entry.token,
|
|
98
|
+
requestId: entry.requestId,
|
|
99
|
+
kind: entry.kind,
|
|
100
|
+
replyText: entry.replyText,
|
|
101
|
+
fromLabel: entry.fromLabel,
|
|
102
|
+
});
|
|
103
|
+
if (result.settled) {
|
|
104
|
+
deleteRemoteSessionReplyOutbox(entry.requestId);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
deferRemoteSessionReplyOutbox({
|
|
108
|
+
requestId: entry.requestId,
|
|
109
|
+
error: result.error,
|
|
110
|
+
now,
|
|
111
|
+
random: retryRandom,
|
|
112
|
+
});
|
|
113
|
+
logger.info(
|
|
114
|
+
{ requestId: entry.requestId, attempts: entry.attempts + 1, error: result.error },
|
|
115
|
+
"session-comm: remote ask reply deferred",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
})().finally(() => {
|
|
119
|
+
inFlight = null;
|
|
120
|
+
});
|
|
121
|
+
return inFlight;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One maintenance pass: flush due answers, forget inbox claims past their
|
|
126
|
+
* 24 h TTL and caller asks past the 15 min pending-ask TTL (and the in-memory
|
|
127
|
+
* ask callbacks past theirs), then re-run inbox deliveries whose lease
|
|
128
|
+
* expired. Returns what was purged, for tests and logs.
|
|
129
|
+
*/
|
|
130
|
+
export async function runRemoteSessionMaintenance(
|
|
131
|
+
now: number = Date.now(),
|
|
132
|
+
): Promise<{ claims: number; asks: number; recovered: number }> {
|
|
133
|
+
await flushRemoteSessionReplyOutbox(now);
|
|
134
|
+
const claims = purgeRemoteSessionInboxClaims(now);
|
|
135
|
+
const asks = purgeStaleRemoteSessionAsks(now);
|
|
136
|
+
purgeStaleAsks(now);
|
|
137
|
+
const recovered = await recoverRemoteSessionInbox(now);
|
|
138
|
+
if (claims || asks || recovered) {
|
|
139
|
+
logger.info({ claims, asks, recovered }, "session-comm: remote session maintenance");
|
|
140
|
+
}
|
|
141
|
+
return { claims, asks, recovered };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Periodic retry + maintenance loop; returns a stop function. */
|
|
145
|
+
export function startRemoteSessionReplyOutboxWorker(
|
|
146
|
+
intervalMs: number = REMOTE_SESSION_REPLY_RETRY_MS,
|
|
147
|
+
): () => void {
|
|
148
|
+
const timer = setInterval(() => {
|
|
149
|
+
void runRemoteSessionMaintenance().catch((err) => {
|
|
150
|
+
logger.warn({ err }, "session-comm: remote session maintenance pass failed");
|
|
151
|
+
});
|
|
152
|
+
}, intervalMs);
|
|
153
|
+
timer.unref?.();
|
|
154
|
+
void runRemoteSessionMaintenance().catch(() => {});
|
|
155
|
+
return () => clearInterval(timer);
|
|
156
|
+
}
|