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.
Files changed (96) hide show
  1. package/dist/agent-helpers.js +1146 -212
  2. package/dist/agent-helpers.js.map +27 -19
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
  6. package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
  7. package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
  8. package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
  9. package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
  10. package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
  11. package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
  12. package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
  13. package/dist/hosted-agent.js +290 -12
  14. package/dist/hosted-agent.js.map +10 -7
  15. package/dist/main.js +4599 -2724
  16. package/dist/main.js.map +49 -38
  17. package/dist/mcp-factories.js +1166 -162
  18. package/dist/mcp-factories.js.map +27 -19
  19. package/dist/media.js.map +1 -1
  20. package/dist/prompts.js.map +1 -1
  21. package/dist/query-runtime.js.map +3 -3
  22. package/dist/registry.js +3 -3
  23. package/dist/rollout.js +2 -2
  24. package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
  25. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
  26. package/dist/runtime/src/agents/public-helpers.ts +5 -0
  27. package/dist/runtime/src/agents/self-config-core.ts +28 -58
  28. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
  29. package/dist/runtime/src/index.ts +75 -0
  30. package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
  31. package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
  32. package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
  33. package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
  34. package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
  35. package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
  36. package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
  37. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
  38. package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
  39. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
  40. package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
  41. package/dist/runtime/src/mcp-runtime-host.ts +2 -1
  42. package/dist/runtime/src/node-host.ts +26 -0
  43. package/dist/runtime/src/platform/mcp-config.ts +56 -2
  44. package/dist/runtime/src/query/active-rooms.ts +6 -0
  45. package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
  46. package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
  47. package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
  48. package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
  49. package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
  50. package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
  51. package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
  52. package/dist/runtime/src/runtime/turn-runner.ts +157 -33
  53. package/dist/runtime/src/runtime-gateway.ts +20 -0
  54. package/dist/runtime/src/storage/api-topics.ts +22 -3
  55. package/dist/runtime/src/storage/remote-session.ts +526 -0
  56. package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
  57. package/dist/runtime/src/topics/derive.ts +3 -0
  58. package/dist/runtime/src/types/api.ts +6 -0
  59. package/dist/runtime/src/types.ts +42 -0
  60. package/dist/runtime/src/version.ts +1 -1
  61. package/dist/runtime-gateway.js.map +2 -2
  62. package/dist/runtime-helpers.js.map +1 -1
  63. package/dist/storage.js +16 -4
  64. package/dist/storage.js.map +4 -4
  65. package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
  66. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
  67. package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
  68. package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
  69. package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
  70. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
  71. package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
  72. package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
  73. package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
  74. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
  75. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
  76. package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
  77. package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
  78. package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
  79. package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
  80. package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
  81. package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
  82. package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
  83. package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
  84. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
  85. package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
  86. package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
  87. package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
  88. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
  89. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  90. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  91. package/dist/types/packages/core/src/types.d.ts +40 -0
  92. package/dist/types/packages/core/src/version.d.ts +1 -1
  93. package/dist/vault.js.map +1 -1
  94. package/package.json +1 -1
  95. /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
  96. /package/dist/{chunk-aqqmzmft.js.map → chunk-nm59q3em.js.map} +0 -0
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Durable state for hub-routed remote session-comm (the `node/topic` branch
3
+ * of `tell_session` / `ask_session` / `abort_session` on the `otium` surface).
4
+ *
5
+ * Three small tables, all keyed by the hub's per-call `requestId`:
6
+ *
7
+ * - `remote_session_inbox_claims` — idempotency for what the hub delivers into
8
+ * this node (`POST /topics/:id/session-comm/inbox`). A claim is a small state
9
+ * machine: `processing` (held under a lease while the delivery is being
10
+ * handed off) → `completed`. Only a `completed` claim answers a retried
11
+ * delivery with a replay; a duplicate that meets a `processing` claim is told
12
+ * to come back (`in_progress`), and a `processing` claim whose lease expired
13
+ * (the process died mid-delivery) is taken over and re-run. The same id with
14
+ * a different payload is a conflict.
15
+ * - `remote_session_asks` — the caller side of a remote `ask_session`: which
16
+ * room asked, so the hub's `ask-reply` delivery can be routed back into it
17
+ * after this process restarted.
18
+ * - `remote_session_reply_outbox` — the target side of a remote ask: the
19
+ * answer waiting to be posted to the hub with its one-shot reply token,
20
+ * retried until the hub acknowledges it or the pending-ask TTL passes.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import { db } from "#storage/forum-db";
24
+ import { PENDING_ASK_TTL_MS } from "#storage/session-asks";
25
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
26
+
27
+ registerStorageSchemaInitializer((database) => {
28
+ database.exec(`
29
+ CREATE TABLE IF NOT EXISTS remote_session_inbox_claims (
30
+ request_id TEXT PRIMARY KEY,
31
+ kind TEXT NOT NULL,
32
+ topic_id TEXT NOT NULL,
33
+ payload_hash TEXT NOT NULL,
34
+ created_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_remote_session_inbox_claims_created
37
+ ON remote_session_inbox_claims(created_at);
38
+ CREATE TABLE IF NOT EXISTS remote_session_asks (
39
+ request_id TEXT PRIMARY KEY,
40
+ caller_topic_id TEXT NOT NULL,
41
+ user_id TEXT NOT NULL,
42
+ from_key TEXT NOT NULL,
43
+ to_key TEXT NOT NULL,
44
+ caller_thread_root_id TEXT,
45
+ created_at INTEGER NOT NULL
46
+ );
47
+ CREATE INDEX IF NOT EXISTS idx_remote_session_asks_caller
48
+ ON remote_session_asks(caller_topic_id);
49
+ CREATE TABLE IF NOT EXISTS remote_session_reply_outbox (
50
+ request_id TEXT PRIMARY KEY,
51
+ hub_url TEXT NOT NULL,
52
+ token TEXT NOT NULL,
53
+ kind TEXT NOT NULL CHECK (kind IN ('reply', 'error')),
54
+ reply_text TEXT NOT NULL,
55
+ from_label TEXT NOT NULL,
56
+ created_at INTEGER NOT NULL,
57
+ attempts INTEGER NOT NULL DEFAULT 0,
58
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
59
+ last_error TEXT
60
+ );
61
+ `);
62
+ // Claim state machine (added after the table existed, so guarded): a claim
63
+ // is `processing` under a lease until its delivery is durably recorded, then
64
+ // `completed`. `payload_json` keeps what a `processing` ask-reply must
65
+ // deliver so an expired lease can be re-run after a restart.
66
+ for (const ddl of [
67
+ "ALTER TABLE remote_session_inbox_claims ADD COLUMN state TEXT NOT NULL DEFAULT 'completed'",
68
+ "ALTER TABLE remote_session_inbox_claims ADD COLUMN lease_until INTEGER NOT NULL DEFAULT 0",
69
+ "ALTER TABLE remote_session_inbox_claims ADD COLUMN payload_json TEXT",
70
+ ]) {
71
+ try {
72
+ database.exec(ddl);
73
+ } catch {
74
+ // Column already present.
75
+ }
76
+ }
77
+ }, 36);
78
+
79
+ /** Claims older than this are forgotten; the hub never retries a call this late. */
80
+ export const REMOTE_SESSION_INBOX_CLAIM_TTL_MS = 24 * 60 * 60 * 1000;
81
+ /** A reply the hub has not acknowledged by then is dropped: the caller's pending ask has timed out. */
82
+ export const REMOTE_SESSION_REPLY_OUTBOX_TTL_MS = PENDING_ASK_TTL_MS;
83
+ /** Poll cadence of the reply outbox worker; individual rows back off (see {@link remoteSessionRetryDelayMs}). */
84
+ export const REMOTE_SESSION_REPLY_RETRY_MS = 5_000;
85
+ /** Backoff ladder for a reply the hub did not accept: 5, 10, 20, 30 s (cap), with jitter. */
86
+ export const REMOTE_SESSION_REPLY_BACKOFF_BASE_MS = 5_000;
87
+ export const REMOTE_SESSION_REPLY_BACKOFF_MAX_MS = 30_000;
88
+ /**
89
+ * How long a `processing` claim is held before another delivery (a hub retry
90
+ * or the maintenance pass) may take it over. Longer than any single delivery
91
+ * attempt, so a live process is never raced by its own retry.
92
+ */
93
+ export const REMOTE_SESSION_INBOX_CLAIM_LEASE_MS = 60_000;
94
+
95
+ export type RemoteSessionInboxKind = "tell" | "ask" | "abort" | "ask-reply";
96
+ export type RemoteSessionInboxClaimState = "processing" | "completed";
97
+ /**
98
+ * `claimed`: this caller now holds the `processing` lease (fresh, or taken
99
+ * over from an expired one). `replay`: a `completed` claim with the same
100
+ * payload. `in_progress`: another delivery holds a live lease. `conflict`:
101
+ * the id is bound to a different payload/kind/room.
102
+ */
103
+ export type RemoteSessionInboxClaimOutcome = "claimed" | "replay" | "in_progress" | "conflict";
104
+
105
+ /**
106
+ * Delay before retry number `attempts` (1-based: the first retry after the
107
+ * immediate attempt is `attempts = 1`): 5, 10, 20, 30 s and 30 s thereafter,
108
+ * each with ±20 % jitter from `random` (injectable for deterministic tests).
109
+ */
110
+ export function remoteSessionRetryDelayMs(
111
+ attempts: number,
112
+ random: () => number = Math.random,
113
+ ): number {
114
+ const step = Math.max(0, Math.floor(attempts) - 1);
115
+ const base = Math.min(
116
+ REMOTE_SESSION_REPLY_BACKOFF_BASE_MS * 2 ** step,
117
+ REMOTE_SESSION_REPLY_BACKOFF_MAX_MS,
118
+ );
119
+ const jitter = 0.8 + 0.4 * Math.min(1, Math.max(0, random()));
120
+ return Math.round(base * jitter);
121
+ }
122
+
123
+ /** Stable digest of a delivery body, so a retried delivery is recognised as one. */
124
+ export function remoteSessionPayloadHash(payload: unknown): string {
125
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
126
+ }
127
+
128
+ interface InboxClaimRow {
129
+ kind: string;
130
+ topic_id: string;
131
+ payload_hash: string;
132
+ state: RemoteSessionInboxClaimState;
133
+ lease_until: number | bigint;
134
+ payload_json: string | null;
135
+ created_at: number | bigint;
136
+ }
137
+
138
+ export interface RemoteSessionInboxClaimRecord {
139
+ requestId: string;
140
+ kind: RemoteSessionInboxKind;
141
+ topicId: string;
142
+ payloadHash: string;
143
+ state: RemoteSessionInboxClaimState;
144
+ leaseUntil: number;
145
+ payload: unknown;
146
+ createdAt: number;
147
+ }
148
+
149
+ /**
150
+ * Claim one hub delivery as `processing` under a lease. For tell/ask/abort
151
+ * the caller completes it in the same transaction as the enqueue; for an
152
+ * ask-reply it stays `processing` — with `payload` persisted — until the
153
+ * answer is durably in the caller's room, or is released so the hub's retry
154
+ * runs the delivery again.
155
+ */
156
+ export function claimRemoteSessionInbox(args: {
157
+ requestId: string;
158
+ kind: RemoteSessionInboxKind;
159
+ topicId: string;
160
+ payloadHash: string;
161
+ /** Persisted while `processing` so an interrupted delivery can be re-run. */
162
+ payload?: unknown;
163
+ now?: number;
164
+ leaseMs?: number;
165
+ /** Take over even a live lease (a fresh process knows nobody holds one). */
166
+ force?: boolean;
167
+ }): RemoteSessionInboxClaimOutcome {
168
+ const now = args.now ?? Date.now();
169
+ const leaseUntil = now + (args.leaseMs ?? REMOTE_SESSION_INBOX_CLAIM_LEASE_MS);
170
+ const payloadJson = args.payload === undefined ? null : JSON.stringify(args.payload);
171
+ return db.transaction((): RemoteSessionInboxClaimOutcome => {
172
+ const inserted = db
173
+ .query(
174
+ `INSERT OR IGNORE INTO remote_session_inbox_claims
175
+ (request_id, kind, topic_id, payload_hash, created_at, state, lease_until, payload_json)
176
+ VALUES (?, ?, ?, ?, ?, 'processing', ?, ?)`,
177
+ )
178
+ .run(args.requestId, args.kind, args.topicId, args.payloadHash, now, leaseUntil, payloadJson);
179
+ if (Number(inserted.changes ?? 0) === 1) return "claimed";
180
+ const existing = db
181
+ .query<InboxClaimRow, [string]>(
182
+ "SELECT * FROM remote_session_inbox_claims WHERE request_id = ?",
183
+ )
184
+ .get(args.requestId);
185
+ if (
186
+ !existing ||
187
+ existing.kind !== args.kind ||
188
+ existing.topic_id !== args.topicId ||
189
+ existing.payload_hash !== args.payloadHash
190
+ ) {
191
+ return "conflict";
192
+ }
193
+ if (existing.state === "completed") return "replay";
194
+ if (!args.force && Number(existing.lease_until) > now) return "in_progress";
195
+ // The previous holder died (or hung past its lease): take the claim over.
196
+ db.query(
197
+ `UPDATE remote_session_inbox_claims
198
+ SET lease_until = ?, payload_json = COALESCE(?, payload_json)
199
+ WHERE request_id = ?`,
200
+ ).run(leaseUntil, payloadJson, args.requestId);
201
+ return "claimed";
202
+ })();
203
+ }
204
+
205
+ /** The delivery is durably recorded: replays may now be acknowledged. */
206
+ export function completeRemoteSessionInboxClaim(
207
+ requestId: string,
208
+ now: number = Date.now(),
209
+ ): boolean {
210
+ const result = db
211
+ .query(
212
+ `UPDATE remote_session_inbox_claims
213
+ SET state = 'completed', lease_until = ?, payload_json = NULL
214
+ WHERE request_id = ? AND state = 'processing'`,
215
+ )
216
+ .run(now, requestId);
217
+ return Number(result.changes ?? 0) === 1;
218
+ }
219
+
220
+ export function getRemoteSessionInboxClaim(
221
+ requestId: string,
222
+ ): RemoteSessionInboxClaimRecord | null {
223
+ const row = db
224
+ .query<InboxClaimRow & { request_id: string }, [string]>(
225
+ "SELECT * FROM remote_session_inbox_claims WHERE request_id = ?",
226
+ )
227
+ .get(requestId);
228
+ return row ? toClaimRecord(row) : null;
229
+ }
230
+
231
+ function toClaimRecord(row: InboxClaimRow & { request_id: string }): RemoteSessionInboxClaimRecord {
232
+ let payload: unknown = null;
233
+ if (row.payload_json) {
234
+ try {
235
+ payload = JSON.parse(row.payload_json);
236
+ } catch {
237
+ payload = null;
238
+ }
239
+ }
240
+ return {
241
+ requestId: row.request_id,
242
+ kind: row.kind as RemoteSessionInboxKind,
243
+ topicId: row.topic_id,
244
+ payloadHash: row.payload_hash,
245
+ state: row.state,
246
+ leaseUntil: Number(row.lease_until),
247
+ payload,
248
+ createdAt: Number(row.created_at),
249
+ };
250
+ }
251
+
252
+ /** `processing` claims whose lease ran out: deliveries a dead process left behind. */
253
+ export function listExpiredRemoteSessionInboxClaims(
254
+ now: number = Date.now(),
255
+ options: { includeLive?: boolean } = {},
256
+ ): RemoteSessionInboxClaimRecord[] {
257
+ return db
258
+ .query<InboxClaimRow & { request_id: string }, [number]>(
259
+ `SELECT * FROM remote_session_inbox_claims
260
+ WHERE state = 'processing' AND lease_until <= ?
261
+ ORDER BY created_at ASC`,
262
+ )
263
+ .all(options.includeLive ? Number.MAX_SAFE_INTEGER : now)
264
+ .map(toClaimRecord);
265
+ }
266
+
267
+ export function releaseRemoteSessionInboxClaim(requestId: string): boolean {
268
+ const result = db
269
+ .query("DELETE FROM remote_session_inbox_claims WHERE request_id = ?")
270
+ .run(requestId);
271
+ return Number(result.changes ?? 0) === 1;
272
+ }
273
+
274
+ export function purgeRemoteSessionInboxClaims(now: number = Date.now()): number {
275
+ const result = db
276
+ .query("DELETE FROM remote_session_inbox_claims WHERE created_at < ?")
277
+ .run(now - REMOTE_SESSION_INBOX_CLAIM_TTL_MS);
278
+ return Number(result.changes ?? 0);
279
+ }
280
+
281
+ export interface RemoteSessionAskRecord {
282
+ requestId: string;
283
+ callerTopicId: string;
284
+ userId: string;
285
+ fromKey: string;
286
+ toKey: string;
287
+ callerThreadRootId?: string;
288
+ createdAt: number;
289
+ }
290
+
291
+ interface RemoteSessionAskRow {
292
+ request_id: string;
293
+ caller_topic_id: string;
294
+ user_id: string;
295
+ from_key: string;
296
+ to_key: string;
297
+ caller_thread_root_id: string | null;
298
+ created_at: number | bigint;
299
+ }
300
+
301
+ function toAskRecord(row: RemoteSessionAskRow): RemoteSessionAskRecord {
302
+ return {
303
+ requestId: row.request_id,
304
+ callerTopicId: row.caller_topic_id,
305
+ userId: row.user_id,
306
+ fromKey: row.from_key,
307
+ toKey: row.to_key,
308
+ ...(row.caller_thread_root_id ? { callerThreadRootId: row.caller_thread_root_id } : {}),
309
+ createdAt: Number(row.created_at),
310
+ };
311
+ }
312
+
313
+ /** Remember an outbound remote ask before the hub is asked to forward it. */
314
+ export function recordRemoteSessionAsk(
315
+ args: Omit<RemoteSessionAskRecord, "createdAt"> & { createdAt?: number },
316
+ ): void {
317
+ db.query(
318
+ `INSERT INTO remote_session_asks
319
+ (request_id, caller_topic_id, user_id, from_key, to_key, caller_thread_root_id, created_at)
320
+ VALUES (?, ?, ?, ?, ?, ?, ?)
321
+ ON CONFLICT(request_id) DO UPDATE SET
322
+ caller_topic_id = excluded.caller_topic_id,
323
+ user_id = excluded.user_id,
324
+ from_key = excluded.from_key,
325
+ to_key = excluded.to_key,
326
+ caller_thread_root_id = excluded.caller_thread_root_id,
327
+ created_at = excluded.created_at`,
328
+ ).run(
329
+ args.requestId,
330
+ args.callerTopicId,
331
+ args.userId,
332
+ args.fromKey,
333
+ args.toKey,
334
+ args.callerThreadRootId ?? null,
335
+ args.createdAt ?? Date.now(),
336
+ );
337
+ }
338
+
339
+ export function getRemoteSessionAsk(requestId: string): RemoteSessionAskRecord | null {
340
+ const row = db
341
+ .query<RemoteSessionAskRow, [string]>("SELECT * FROM remote_session_asks WHERE request_id = ?")
342
+ .get(requestId);
343
+ return row ? toAskRecord(row) : null;
344
+ }
345
+
346
+ export function deleteRemoteSessionAsk(requestId: string): boolean {
347
+ const result = db.query("DELETE FROM remote_session_asks WHERE request_id = ?").run(requestId);
348
+ return Number(result.changes ?? 0) === 1;
349
+ }
350
+
351
+ /** Consume the caller record exactly once: the row is gone after this returns it. */
352
+ export function takeRemoteSessionAsk(requestId: string): RemoteSessionAskRecord | null {
353
+ return db.transaction(() => {
354
+ const record = getRemoteSessionAsk(requestId);
355
+ if (record) deleteRemoteSessionAsk(requestId);
356
+ return record;
357
+ })();
358
+ }
359
+
360
+ export function deleteRemoteSessionAsksForTopic(callerTopicId: string): number {
361
+ const result = db
362
+ .query("DELETE FROM remote_session_asks WHERE caller_topic_id = ?")
363
+ .run(callerTopicId);
364
+ return Number(result.changes ?? 0);
365
+ }
366
+
367
+ export function purgeStaleRemoteSessionAsks(now: number = Date.now()): number {
368
+ const result = db
369
+ .query("DELETE FROM remote_session_asks WHERE created_at < ?")
370
+ .run(now - PENDING_ASK_TTL_MS);
371
+ return Number(result.changes ?? 0);
372
+ }
373
+
374
+ export interface RemoteSessionReplyOutboxEntry {
375
+ requestId: string;
376
+ hubUrl: string;
377
+ token: string;
378
+ kind: "reply" | "error";
379
+ replyText: string;
380
+ fromLabel: string;
381
+ createdAt: number;
382
+ attempts: number;
383
+ nextAttemptAt: number;
384
+ lastError?: string;
385
+ }
386
+
387
+ interface RemoteSessionReplyOutboxRow {
388
+ request_id: string;
389
+ hub_url: string;
390
+ token: string;
391
+ kind: "reply" | "error";
392
+ reply_text: string;
393
+ from_label: string;
394
+ created_at: number | bigint;
395
+ attempts: number | bigint;
396
+ next_attempt_at: number | bigint;
397
+ last_error: string | null;
398
+ }
399
+
400
+ function toOutboxEntry(row: RemoteSessionReplyOutboxRow): RemoteSessionReplyOutboxEntry {
401
+ return {
402
+ requestId: row.request_id,
403
+ hubUrl: row.hub_url,
404
+ token: row.token,
405
+ kind: row.kind,
406
+ replyText: row.reply_text,
407
+ fromLabel: row.from_label,
408
+ createdAt: Number(row.created_at),
409
+ attempts: Number(row.attempts),
410
+ nextAttemptAt: Number(row.next_attempt_at),
411
+ ...(row.last_error ? { lastError: row.last_error } : {}),
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Queue an answer for the hub. One row per ask: a second answer for the same
417
+ * request (a retry after a partial failure) replaces the first, which is what
418
+ * the hub's one-shot reply token would enforce anyway.
419
+ */
420
+ export function upsertRemoteSessionReplyOutbox(args: {
421
+ requestId: string;
422
+ hubUrl: string;
423
+ token: string;
424
+ kind: "reply" | "error";
425
+ replyText: string;
426
+ fromLabel: string;
427
+ createdAt?: number;
428
+ }): void {
429
+ db.query(
430
+ `INSERT INTO remote_session_reply_outbox
431
+ (request_id, hub_url, token, kind, reply_text, from_label, created_at, attempts, next_attempt_at, last_error)
432
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL)
433
+ ON CONFLICT(request_id) DO UPDATE SET
434
+ hub_url = excluded.hub_url,
435
+ token = excluded.token,
436
+ kind = excluded.kind,
437
+ reply_text = excluded.reply_text,
438
+ from_label = excluded.from_label,
439
+ attempts = 0,
440
+ next_attempt_at = 0,
441
+ last_error = NULL`,
442
+ ).run(
443
+ args.requestId,
444
+ args.hubUrl,
445
+ args.token,
446
+ args.kind,
447
+ args.replyText,
448
+ args.fromLabel,
449
+ args.createdAt ?? Date.now(),
450
+ );
451
+ }
452
+
453
+ export function listRemoteSessionReplyOutbox(
454
+ args: { now?: number; limit?: number; due?: boolean } = {},
455
+ ): RemoteSessionReplyOutboxEntry[] {
456
+ const now = args.now ?? Date.now();
457
+ const rows =
458
+ args.due === false
459
+ ? db
460
+ .query<RemoteSessionReplyOutboxRow, [number]>(
461
+ "SELECT * FROM remote_session_reply_outbox ORDER BY created_at LIMIT ?",
462
+ )
463
+ .all(args.limit ?? 100)
464
+ : db
465
+ .query<RemoteSessionReplyOutboxRow, [number, number]>(
466
+ `SELECT * FROM remote_session_reply_outbox
467
+ WHERE next_attempt_at <= ?
468
+ ORDER BY created_at LIMIT ?`,
469
+ )
470
+ .all(now, args.limit ?? 100);
471
+ return rows.map(toOutboxEntry);
472
+ }
473
+
474
+ export function deleteRemoteSessionReplyOutbox(requestId: string): boolean {
475
+ const result = db
476
+ .query("DELETE FROM remote_session_reply_outbox WHERE request_id = ?")
477
+ .run(requestId);
478
+ return Number(result.changes ?? 0) === 1;
479
+ }
480
+
481
+ /**
482
+ * Record a failed attempt and schedule the next one on the backoff ladder
483
+ * (5, 10, 20, 30 s cap, jittered) — the immediate first attempt is
484
+ * `attempts = 0`, so the first deferral waits ≈5 s. `retryMs` pins the delay.
485
+ */
486
+ export function deferRemoteSessionReplyOutbox(args: {
487
+ requestId: string;
488
+ error: string;
489
+ now?: number;
490
+ retryMs?: number;
491
+ random?: () => number;
492
+ }): void {
493
+ const now = args.now ?? Date.now();
494
+ db.transaction(() => {
495
+ const row = db
496
+ .query<{ attempts: number | bigint }, [string]>(
497
+ "SELECT attempts FROM remote_session_reply_outbox WHERE request_id = ?",
498
+ )
499
+ .get(args.requestId);
500
+ if (!row) return;
501
+ const attempts = Number(row.attempts) + 1;
502
+ const delay = args.retryMs ?? remoteSessionRetryDelayMs(attempts, args.random);
503
+ db.query(
504
+ `UPDATE remote_session_reply_outbox
505
+ SET attempts = ?, next_attempt_at = ?, last_error = ?
506
+ WHERE request_id = ?`,
507
+ ).run(attempts, now + delay, args.error.slice(0, 500), args.requestId);
508
+ })();
509
+ }
510
+
511
+ /** Drop answers nobody is waiting for any more. Returns what was dropped, for the log. */
512
+ export function purgeExpiredRemoteSessionReplyOutbox(
513
+ now: number = Date.now(),
514
+ ): RemoteSessionReplyOutboxEntry[] {
515
+ const cutoff = now - REMOTE_SESSION_REPLY_OUTBOX_TTL_MS;
516
+ const expired = db
517
+ .query<RemoteSessionReplyOutboxRow, [number]>(
518
+ "SELECT * FROM remote_session_reply_outbox WHERE created_at < ?",
519
+ )
520
+ .all(cutoff)
521
+ .map(toOutboxEntry);
522
+ if (expired.length) {
523
+ db.query("DELETE FROM remote_session_reply_outbox WHERE created_at < ?").run(cutoff);
524
+ }
525
+ return expired;
526
+ }
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { intersectActorTopicScopes } from "#runtime/actor-topic-scope";
3
+ import { mergeRemoteSessionGrants } from "#runtime/remote-session-grant";
2
4
  import {
3
5
  flattenUserTurnAttachments,
4
6
  legacyUserTurnEnvelope,
@@ -10,7 +12,13 @@ import { TURN_LEASE_STALE_MS } from "#storage/runtime-leases";
10
12
  import { getRuntimeTopicEpoch, TOPIC_MAINTENANCE_STALE_MS } from "#storage/runtime-topic-state";
11
13
  import type { StorageDatabase } from "#storage/storage-contract";
12
14
  import { registerStorageSchemaInitializer } from "#storage/storage-host";
13
- import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext } from "#types";
15
+ import type {
16
+ ActorTopicScope,
17
+ AgentKind,
18
+ EffortLevel,
19
+ PeerRuntimeBridgeContext,
20
+ RemoteSessionGrant,
21
+ } from "#types";
14
22
 
15
23
  const REQUEST_CLAIM_STALE_MS = TURN_LEASE_STALE_MS;
16
24
 
@@ -37,6 +45,10 @@ export interface RuntimeUserTurnExecution {
37
45
  vaultUserId?: string;
38
46
  /** Product actor identity, independent from the canonical execution principal. */
39
47
  actorUserId?: string;
48
+ /** Host-asserted rooms the actor may reach; kept with the row so retries keep it. */
49
+ actorTopicScope?: ActorTopicScope;
50
+ /** Hub-issued per-turn remote session-comm grant; kept with the row like the assertion. */
51
+ remoteSession?: RemoteSessionGrant;
40
52
  /** Newly accepted user texts not yet recorded in the unified conversation log. */
41
53
  conversationPrompts?: string[];
42
54
  /** Number of leading userMessages already present in the unified conversation log. */
@@ -306,6 +318,17 @@ export function enqueueRuntimeUserTurnRequest(input: {
306
318
  return requestId;
307
319
  }
308
320
 
321
+ /**
322
+ * Who a request speaks for. `null` when no actor was recorded (a turn the node
323
+ * started for itself, or a row older than the field): two such requests are
324
+ * one conversation, but an unrecorded actor never matches a recorded one — the
325
+ * conservative reading, since the alternative is folding a stranger's message
326
+ * into a turn whose author is unknown.
327
+ */
328
+ function requestActor(actorUserId: string | undefined): string | null {
329
+ return actorUserId ?? null;
330
+ }
331
+
309
332
  function loggedMessageCount(request: RuntimeUserTurnRequest): number {
310
333
  const explicit = request.execution?.loggedUserMessageCount;
311
334
  if (typeof explicit === "number" && Number.isInteger(explicit)) {
@@ -350,9 +373,25 @@ export function mergeRuntimeUserTurnRequest(input: {
350
373
  // and a pending request from another thread is left standing to run on
351
374
  // its own (S-13).
352
375
  const thread = input.execution.threadRootId;
353
- const previous = rows
376
+ const sameThread = rows
354
377
  .map(rowToRequest)
355
378
  .filter((request) => request.execution?.threadRootId === thread);
379
+ // Nor is a different person the same conversation for this purpose. A
380
+ // merged batch runs with one actor's identity and one room assertion,
381
+ // so folding B's follow-up onto A's pending question would run A's
382
+ // words under B's authority (or B's under A's). Only the most recent
383
+ // unbroken run of the *same* actor's requests is folded; a request from
384
+ // anyone else ends that run and keeps its own row, in arrival order, so
385
+ // each turn carries exactly the actor and assertion it was sent with.
386
+ const actor = requestActor(input.execution.actorUserId);
387
+ let firstMergeable = sameThread.length;
388
+ while (
389
+ firstMergeable > 0 &&
390
+ requestActor(sameThread[firstMergeable - 1]!.execution?.actorUserId) === actor
391
+ ) {
392
+ firstMergeable -= 1;
393
+ }
394
+ const previous = sameThread.slice(firstMergeable);
356
395
  const omittedRequestIds = new Set([
357
396
  ...(input.omitRequestIds ?? []),
358
397
  ...previous
@@ -392,8 +431,24 @@ export function mergeRuntimeUserTurnRequest(input: {
392
431
  const loggedUserMessageCount =
393
432
  carried.reduce((count, request) => count + loggedMessageCount(request), 0) +
394
433
  Math.max(0, incomingLoggedCount - alreadyIncludedMessageCount);
434
+ // Same actor, possibly different assertions (the hub's roster moved
435
+ // between two messages): the merged turn gets the intersection, and no
436
+ // assertion at all if any folded request lacked one. Narrowing is the
437
+ // only safe direction for a batch that speaks with one voice.
438
+ const actorTopicScope = intersectActorTopicScopes([
439
+ ...previous.map((request) => request.execution?.actorTopicScope),
440
+ input.execution.actorTopicScope,
441
+ ]);
442
+ // Same rule for the remote-session grant: the batch keeps one only when
443
+ // every folded request carried one (for the same hub), the latest-expiring.
444
+ const remoteSession = mergeRemoteSessionGrants([
445
+ ...previous.map((request) => request.execution?.remoteSession),
446
+ input.execution.remoteSession,
447
+ ]);
395
448
  const execution: RuntimeUserTurnExecution = {
396
449
  ...input.execution,
450
+ actorTopicScope,
451
+ remoteSession,
397
452
  loggedUserMessageCount,
398
453
  supersededRequestIds: [
399
454
  ...new Set(
@@ -681,6 +736,24 @@ export function cancelRuntimeUserTurnRequests(topicId: string): string[] {
681
736
  return rows.map((row) => row.request_id);
682
737
  }
683
738
 
739
+ /**
740
+ * The request a worker currently holds for `topicId` — claimed or already
741
+ * running — if any. This is the turn a new human message could steer; whether
742
+ * it may is decided by whoever folds the new message (see
743
+ * `submitRuntimeGatewayTurn`).
744
+ */
745
+ export function getActiveRuntimeUserTurnRequest(topicId: string): RuntimeUserTurnRequest | null {
746
+ const row = db
747
+ .query<RuntimeUserTurnRequestRow, [string, number]>(
748
+ `SELECT * FROM runtime_user_turn_requests
749
+ WHERE topic_id = ?
750
+ AND (status = 'running' OR (claimed_by IS NOT NULL AND claimed_at >= ?))
751
+ ORDER BY created_at ASC, rowid ASC LIMIT 1`,
752
+ )
753
+ .get(topicId, Date.now() - REQUEST_CLAIM_STALE_MS);
754
+ return row ? rowToRequest(row) : null;
755
+ }
756
+
684
757
  export function getRuntimeUserTurnRequest(topicId: string): RuntimeUserTurnRequest | null {
685
758
  const row = db
686
759
  .query<RuntimeUserTurnRequestRow, [string]>(
@@ -203,6 +203,8 @@ function captureForkSnapshot(
203
203
 
204
204
  interface DerivedTopicOptions {
205
205
  name?: string;
206
+ /** Product actor who asked for the derive, when `userId` is a shared principal. */
207
+ derivedByUserId?: string;
206
208
  subagent?: { agent?: AgentKind; model?: string; memoryTopicId?: string };
207
209
  summarizeFork?: (request: CompactSummaryRequest) => Promise<string>;
208
210
  }
@@ -312,6 +314,7 @@ async function createDerivedTopicImpl(
312
314
  parentTopicId: sourceTopicId,
313
315
  ...(subagent?.memoryTopicId ? { memoryTopicId: subagent.memoryTopicId } : {}),
314
316
  isFork: copyHistory,
317
+ ...(opts?.derivedByUserId ? { derivedByUserId: opts.derivedByUserId } : {}),
315
318
  ...(subagent ? { isSubagent: true } : {}),
316
319
  visibility: topic.visibility,
317
320
  // A derived room lives on the same surface as the room it came from.
@@ -120,6 +120,12 @@ export interface TopicDto {
120
120
  memoryKey?: string;
121
121
  /** True when the topic was created via fork (copies history). */
122
122
  isFork?: boolean;
123
+ /**
124
+ * The person whose turn asked for this spawn/fork, when the node executed it
125
+ * under a shared principal. A host that mirrors the room seeds its owner from
126
+ * this; absent for rooms not derived by an agent tool.
127
+ */
128
+ derivedByUserId?: string;
123
129
  /** True when this topic was spawned by an agent as a subagent worker room. */
124
130
  isSubagent?: boolean;
125
131
  /** Extra tell_session targets granted to this subagent; direct parent is implicit. */