@amaster.ai/dsh-a2a 0.1.34 → 0.1.36

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/lib/inbox.js CHANGED
@@ -1,17 +1,31 @@
1
1
  /** Native DSH input ownership with standard A2A task delivery. */
2
2
  import { randomUUID } from 'node:crypto';
3
- import { Message, TaskState } from '@a2a-js/sdk';
3
+ import { Message, Role, TaskState } from '@a2a-js/sdk';
4
+ import { RequestMalformedError } from '@a2a-js/sdk/errors';
4
5
  import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
5
6
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
7
+ import { InboxChanges } from './inbox-changes.js';
6
8
  import { commitInboxInput, markInboxInputPending } from './task-store.js';
7
9
  import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
8
- export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md';
10
+ export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/packages/dsh-a2a/README.md';
9
11
  const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
12
+ export function validateInboxPage(page) {
13
+ if (page === undefined)
14
+ return;
15
+ if (!page ||
16
+ typeof page !== 'object' ||
17
+ Array.isArray(page) ||
18
+ (page.pageSize !== undefined &&
19
+ (!Number.isInteger(page.pageSize) || page.pageSize < 1 || page.pageSize > 100)) ||
20
+ (page.pageToken !== undefined && typeof page.pageToken !== 'string'))
21
+ throw new RequestMalformedError('pageSize must be an integer from 1 to 100 and pageToken must be a string.');
22
+ }
10
23
  export class NativeInbox {
11
24
  ctx;
12
25
  bridge;
13
26
  store;
14
27
  retainPending;
28
+ changes;
15
29
  contexts = new Map();
16
30
  resolving = new Map();
17
31
  stopping = false;
@@ -19,17 +33,20 @@ export class NativeInbox {
19
33
  preadmitted = new WeakMap();
20
34
  server;
21
35
  persistence;
22
- constructor(ctx, bridge, store, retainPending = false) {
36
+ constructor(ctx, bridge, store, retainPending = false, notifications = false) {
23
37
  this.ctx = ctx;
24
38
  this.bridge = bridge;
25
39
  this.store = store;
26
40
  this.retainPending = retainPending;
41
+ this.changes = new InboxChanges(ctx, (contextId) => this.contexts.has(contextId), notifications);
27
42
  bridge.inbox = this;
28
43
  this.persistence = ctx.get('sessionPersistence');
29
44
  if (retainPending)
30
45
  ctx.on('session/event', (session, event) => {
31
46
  const run = this.contexts.get(session.id);
32
- if (!run || event.type !== 'agent/inbox/spliced' || this.stopping || this.unloading())
47
+ // Shutdown drains in-flight commits before disposal; those splices must
48
+ // still update retained input or recovery would see history without a task.
49
+ if (!run || event.type !== 'agent/inbox/spliced' || this.unloading())
33
50
  return;
34
51
  try {
35
52
  const splice = event.data;
@@ -110,6 +127,236 @@ export class NativeInbox {
110
127
  async resolve(contextId) {
111
128
  return (await this.context(contextId)).entry.handle.agent;
112
129
  }
130
+ async get(contextId, page) {
131
+ validateInboxPage(page);
132
+ let offset = 0;
133
+ let cursorVersion;
134
+ if (page?.pageToken) {
135
+ try {
136
+ const cursor = JSON.parse(Buffer.from(page.pageToken, 'base64url').toString('utf8'));
137
+ if (!Array.isArray(cursor) ||
138
+ cursor.length !== 3 ||
139
+ cursor[0] !== contextId ||
140
+ !Number.isSafeInteger(cursor[1]) ||
141
+ !Number.isSafeInteger(cursor[2]) ||
142
+ cursor[2] < 0)
143
+ throw new Error();
144
+ [, cursorVersion, offset] = cursor;
145
+ }
146
+ catch {
147
+ throw new RequestMalformedError('Invalid inbox page token.');
148
+ }
149
+ }
150
+ const run = await this.context(contextId);
151
+ const agent = run.entry.handle.agent;
152
+ const version = agent.session.seq - 1;
153
+ const steps = agent.inbox.nextStep;
154
+ const turns = agent.inbox.nextTurn;
155
+ const totalSize = steps.length + turns.length;
156
+ if ((cursorVersion !== undefined && cursorVersion !== version) || offset > totalSize)
157
+ throw new RequestMalformedError('Inbox page is stale; read again without pageToken.');
158
+ const limit = page === undefined ? totalSize : (page.pageSize ?? 50);
159
+ const entry = (message) => {
160
+ const task = this.owner(run, message);
161
+ const protocol = task
162
+ ? this.protocolMessage(run, task, message)
163
+ : Message.fromJSON({
164
+ messageId: message.id,
165
+ contextId,
166
+ role: 'ROLE_USER',
167
+ parts: message.content
168
+ .filter((block) => block.type === 'text')
169
+ .map((block) => ({ text: block.text })),
170
+ });
171
+ return {
172
+ id: message.id,
173
+ message: Message.toJSON(protocol),
174
+ editable: message.source.kind === 'a2a',
175
+ };
176
+ };
177
+ const nextStep = [];
178
+ const nextTurn = [];
179
+ let count = 0;
180
+ let bytes = 2;
181
+ for (let index = offset; index < totalSize && count < limit; index++) {
182
+ if (page !== undefined && count && bytes >= 1024 * 1024)
183
+ break;
184
+ const step = index < steps.length;
185
+ const item = entry(step ? steps[index] : turns[index - steps.length]);
186
+ if (page !== undefined) {
187
+ const size = Buffer.byteLength(JSON.stringify(item)) + (count ? 1 : 0);
188
+ // Keep each A2A Message intact, allowing one oversized item to make progress.
189
+ if (count && bytes + size > 1024 * 1024)
190
+ break;
191
+ bytes += size;
192
+ }
193
+ (step ? nextStep : nextTurn).push(item);
194
+ count++;
195
+ }
196
+ return {
197
+ contextId,
198
+ version,
199
+ nextTurn,
200
+ nextStep,
201
+ totalSize,
202
+ nextPageToken: offset + count < totalSize
203
+ ? Buffer.from(JSON.stringify([contextId, version, offset + count])).toString('base64url')
204
+ : '',
205
+ };
206
+ }
207
+ async editQueue(contextId, edit, page) {
208
+ validateInboxPage(page);
209
+ if (page?.pageToken)
210
+ throw new RequestMalformedError('pageToken is only supported by inbox get.');
211
+ const run = await this.context(contextId);
212
+ const operation = run.admissionTail.then(async () => {
213
+ if (this.stopping || this.bridge.isClearing(contextId))
214
+ throw new RequestMalformedError('Inbox is unloading or clearing.');
215
+ await edit(run);
216
+ await this.flush(run.entry.handle.agent.session);
217
+ });
218
+ run.admissionTail = operation.catch(() => { });
219
+ await operation;
220
+ return this.get(contextId, page);
221
+ }
222
+ queued(run, id) {
223
+ const agent = run.entry.handle.agent;
224
+ const target = agent.inbox.nextTurn.some((message) => message.id === id)
225
+ ? 'next-turn'
226
+ : 'next-step';
227
+ const items = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
228
+ const index = items.findIndex((message) => message.id === id);
229
+ const message = items[index];
230
+ if (!message)
231
+ throw new RequestMalformedError('Input is no longer pending.');
232
+ return { agent, target, items, index, message };
233
+ }
234
+ async update(contextId, id, message, page) {
235
+ return this.editQueue(contextId, async (run) => {
236
+ const queued = this.queued(run, id);
237
+ const task = this.owner(run, queued.message);
238
+ if (queued.message.source.kind !== 'a2a' || !task)
239
+ throw new RequestMalformedError('Only task-attributed A2A input can be edited.');
240
+ const original = this.protocolMessage(run, task, queued.message);
241
+ if (message.messageId !== original.messageId ||
242
+ message.taskId !== original.taskId ||
243
+ message.contextId !== contextId ||
244
+ message.role !== Role.ROLE_USER ||
245
+ !message.parts.length ||
246
+ message.parts.some((part) => !part.content))
247
+ throw new RequestMalformedError('A user Message with unchanged messageId, taskId and contextId is required.');
248
+ const content = await this.content(message, contextId);
249
+ if (this.stopping ||
250
+ this.bridge.isClearing(contextId) ||
251
+ this.queued(run, id).message !== queued.message)
252
+ throw new RequestMalformedError('Input changed while editing.');
253
+ await this.ctx.serial('a2a/before-followup', {
254
+ agent: queued.agent,
255
+ contextId,
256
+ taskId: task.task.id,
257
+ a2aMessage: message,
258
+ dshMessageId: id,
259
+ requestHeaders: task.headers.get(id),
260
+ signal: task.controller.signal,
261
+ });
262
+ if (this.stopping ||
263
+ this.bridge.isClearing(contextId) ||
264
+ this.queued(run, id).message !== queued.message)
265
+ throw new RequestMalformedError('Input changed while editing.');
266
+ const replacement = {
267
+ ...queued.message,
268
+ content,
269
+ source: {
270
+ ...queued.message.source,
271
+ message: JSON.stringify(Message.toJSON(message)),
272
+ },
273
+ };
274
+ if (!queued.agent.inbox.replace(queued.message.id, replacement))
275
+ throw new RequestMalformedError('Input has already been claimed.');
276
+ task.task.history = task.task.history.map((item) => item.messageId === original.messageId
277
+ ? markInboxInputPending(structuredClone(message))
278
+ : item);
279
+ await this.checkpoint(task.task, task.context);
280
+ }, page);
281
+ }
282
+ async move(contextId, id, index, page) {
283
+ return this.editQueue(contextId, (run) => this.reorder(this.queued(run, id), index), page);
284
+ }
285
+ reorder(queued, index) {
286
+ if (!Number.isInteger(index) || index < 0 || index >= queued.items.length)
287
+ throw new RequestMalformedError('index must address the same pending list.');
288
+ if (index === queued.index)
289
+ return;
290
+ const start = Math.min(queued.index, index);
291
+ const items = queued.items.slice(start, Math.max(queued.index, index) + 1);
292
+ items.splice(queued.index - start, 1);
293
+ items.splice(index - start, 0, queued.message);
294
+ // One atomic native splice records only the affected interval.
295
+ queued.agent.inbox.splice(queued.target, start, items.length, items);
296
+ }
297
+ async removed(run, messages) {
298
+ const removed = new Map();
299
+ for (const message of messages) {
300
+ const task = this.owner(run, message);
301
+ if (!task || message.source.kind !== 'a2a')
302
+ continue;
303
+ let ids = removed.get(task);
304
+ if (!ids) {
305
+ ids = new Set();
306
+ removed.set(task, ids);
307
+ }
308
+ ids.add(Message.fromJSON(JSON.parse(message.source.message)).messageId);
309
+ }
310
+ // One queue durability barrier precedes all terminal task publications.
311
+ await this.flush(run.entry.handle.agent.session);
312
+ const pending = new Set(this.pending(run).map((message) => this.owner(run, message)));
313
+ const entries = removed.entries();
314
+ const errors = [];
315
+ await Promise.all(Array.from({ length: Math.min(4, removed.size) }, async () => {
316
+ for (const [task, ids] of entries) {
317
+ if (task.settled)
318
+ continue;
319
+ try {
320
+ task.task.history = task.task.history.filter((message) => !ids.has(message.messageId));
321
+ if (run.active !== task && !run.claimed.has(task) && !pending.has(task))
322
+ await this.cancelTask(run, task, false);
323
+ else
324
+ await this.checkpoint(task.task, task.context);
325
+ }
326
+ catch (error) {
327
+ if (!errors.length)
328
+ errors.push(error);
329
+ }
330
+ }
331
+ }));
332
+ if (errors.length)
333
+ throw errors[0];
334
+ }
335
+ async remove(contextId, id, page) {
336
+ return this.editQueue(contextId, async (run) => {
337
+ const { agent, message } = this.queued(run, id);
338
+ agent.inbox.remove(message.id);
339
+ await this.removed(run, [message]);
340
+ }, page);
341
+ }
342
+ async clearPending(contextId, page) {
343
+ return this.editQueue(contextId, async (run) => {
344
+ const messages = this.pending(run);
345
+ run.entry.handle.agent.inbox.clear();
346
+ await this.removed(run, messages);
347
+ }, page);
348
+ }
349
+ async executePending(contextId, id, page) {
350
+ return this.editQueue(contextId, async (run) => {
351
+ if (id !== undefined) {
352
+ const queued = this.queued(run, id);
353
+ if (queued.target !== 'next-turn')
354
+ throw new RequestMalformedError('Execute selects next-turn input; use steer for next-step input.');
355
+ this.reorder(queued, 0);
356
+ }
357
+ await this.wake(contextId);
358
+ }, page);
359
+ }
113
360
  async enqueue(taskId, message, options = {}) {
114
361
  if (this.stopping)
115
362
  throw new Error('Native inbox is unloading.');
@@ -138,7 +385,7 @@ export class NativeInbox {
138
385
  const items = from === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
139
386
  const index = items.findIndex((item) => item.id === message.id);
140
387
  if (index < 0)
141
- throw new Error('Queued input has already been claimed.');
388
+ throw new RequestMalformedError('Queued input has already been claimed.');
142
389
  agent.session.append('agent/inbox/spliced', {
143
390
  target: from,
144
391
  start: index,
@@ -164,10 +411,10 @@ export class NativeInbox {
164
411
  const target = run.tasks.get(taskId);
165
412
  const message = agent.inbox.nextTurn.find((item) => item.id === messageId);
166
413
  if (message?.source.kind !== 'a2a')
167
- throw new Error('Queued input has already been claimed.');
414
+ throw new RequestMalformedError('Queued input has already been claimed.');
168
415
  const source = run.tasks.get(message.source.taskId);
169
416
  if (!target || !source)
170
- throw new Error('Promotion requires live source and target tasks.');
417
+ throw new RequestMalformedError('Promotion requires live source and target tasks.');
171
418
  const valid = () => !this.stopping &&
172
419
  !this.bridge.isClearing(run.entry.sessionId) &&
173
420
  !target.settled &&
@@ -181,7 +428,7 @@ export class NativeInbox {
181
428
  !run.claimed.has(source) &&
182
429
  agent.inbox.nextTurn.some((item) => item === message);
183
430
  if (!valid())
184
- throw new Error('Promotion requires an unclaimed independent input and an active target.');
431
+ throw new RequestMalformedError('Promotion requires an unclaimed independent input and an active target.');
185
432
  const original = Message.fromJSON(JSON.parse(message.source.message));
186
433
  original.taskId = taskId;
187
434
  original.referenceTaskIds = [...new Set([...original.referenceTaskIds, source.task.id])];
@@ -201,11 +448,11 @@ export class NativeInbox {
201
448
  try {
202
449
  await this.checkpoint(target.task, target.context);
203
450
  if (!valid())
204
- throw new Error('Promotion raced input consumption or task settlement.');
451
+ throw new RequestMalformedError('Promotion raced input consumption or task settlement.');
205
452
  target.task.history = [...target.task.history, markInboxInputPending(original)];
206
453
  target.headers.set(message.id, source.headers.get(message.id));
207
454
  if (!agent.inbox.replace(message.id, promoted))
208
- throw new Error('Queued input has already been claimed.');
455
+ throw new RequestMalformedError('Queued input has already been claimed.');
209
456
  this.moveInput(agent, promoted, 'next-step');
210
457
  try {
211
458
  await this.flush(agent.session);
@@ -244,7 +491,7 @@ export class NativeInbox {
244
491
  if (this.stopping ||
245
492
  this.contexts.get(run.entry.sessionId) !== run ||
246
493
  this.bridge.isClearing(run.entry.sessionId))
247
- throw new Error('Promotion was interrupted by context disposal.');
494
+ throw new RequestMalformedError('Promotion was interrupted by context disposal.');
248
495
  source.controller.abort();
249
496
  try {
250
497
  await this.checkpoint(target.task, target.context);
@@ -259,6 +506,59 @@ export class NativeInbox {
259
506
  this.settle(run, target);
260
507
  }
261
508
  }
509
+ async resume(contextId, taskId) {
510
+ const context = new ServerCallContext();
511
+ const previous = await this.store.load(taskId, context);
512
+ if (!previous || previous.contextId !== contextId || !this.terminal(previous))
513
+ throw new Error('Resume requires a terminal retained task.');
514
+ const retained = previous.metadata?.[RETAINED_INPUTS];
515
+ if (typeof retained !== 'string')
516
+ throw new Error('Task has no retained input to resume.');
517
+ const inputs = JSON.parse(retained);
518
+ if (!inputs.length)
519
+ throw new Error('Task has no retained input to resume.');
520
+ const run = await this.context(contextId, context);
521
+ const id = randomUUID();
522
+ const replacement = {
523
+ ...previous,
524
+ id,
525
+ status: {
526
+ state: TaskState.TASK_STATE_SUBMITTED,
527
+ timestamp: new Date().toISOString(),
528
+ message: undefined,
529
+ },
530
+ history: [],
531
+ metadata: { ...previous.metadata },
532
+ };
533
+ delete replacement.metadata[RETAINED_INPUTS];
534
+ const retainedInputs = inputs.map((input) => {
535
+ if (input.message.source.kind !== 'a2a')
536
+ throw new Error('Retained input has invalid task ownership.');
537
+ const original = Message.fromJSON(JSON.parse(input.message.source.message));
538
+ original.taskId = id;
539
+ original.referenceTaskIds = [...new Set([...original.referenceTaskIds, taskId])];
540
+ replacement.history.push(markInboxInputPending(original));
541
+ return {
542
+ ...input,
543
+ message: {
544
+ ...input.message,
545
+ source: {
546
+ ...input.message.source,
547
+ taskId: id,
548
+ message: JSON.stringify(Message.toJSON(original)),
549
+ },
550
+ },
551
+ };
552
+ });
553
+ await this.checkpoint(replacement, context);
554
+ for (const [index, input] of retainedInputs.entries()) {
555
+ if (this.pending(run).some((pending) => pending.id === input.message.id))
556
+ run.entry.handle.agent.inbox.remove(input.message.id);
557
+ run.entry.handle.agent.send(input.message, input.target, index === retainedInputs.length - 1);
558
+ }
559
+ this.restoreTask(run, replacement, context);
560
+ return id;
561
+ }
262
562
  /** Repair only an incomplete ownership move, never an ordinary DSH claim. */
263
563
  async recoverPromotions(run, context) {
264
564
  const agent = run.entry.handle.agent;
@@ -335,9 +635,11 @@ export class NativeInbox {
335
635
  const retained = task.metadata?.[RETAINED_INPUTS];
336
636
  if (typeof retained !== 'string')
337
637
  continue;
338
- snapshots.push(task);
638
+ // Terminal snapshots belong to explicit resume(); do not consume their
639
+ // retained metadata during ordinary context recovery.
339
640
  if (this.terminal(task))
340
641
  continue;
642
+ snapshots.push(task);
341
643
  for (const input of JSON.parse(retained)) {
342
644
  const { message } = input;
343
645
  if (message.source.kind !== 'a2a' || message.source.taskId !== task.id)
@@ -348,18 +650,87 @@ export class NativeInbox {
348
650
  }
349
651
  pageToken = page.nextPageToken;
350
652
  } while (pageToken);
351
- for (const { message, target } of inputs.sort((a, b) => a.position - b.position)) {
653
+ const queuedAtOf = (input) => input.message.source.kind === 'a2a' ? (input.message.source.queuedAt ?? 0) : 0;
654
+ // Equal per-task positions must not inherit the store's timestamp ordering.
655
+ const ordered = inputs.sort((a, b) => a.position - b.position || queuedAtOf(a) - queuedAtOf(b));
656
+ const destinationIds = {
657
+ 'next-step': new Set(agent.inbox.nextStep.map((message) => message.id)),
658
+ 'next-turn': new Set(agent.inbox.nextTurn.map((message) => message.id)),
659
+ };
660
+ const existingIds = new Set([...destinationIds['next-step'], ...destinationIds['next-turn']]);
661
+ const byPosition = new Map();
662
+ for (const input of ordered) {
663
+ const positions = byPosition.get(input.target) ?? new Map();
664
+ const group = positions.get(input.position) ?? [];
665
+ group.push(input);
666
+ positions.set(input.position, group);
667
+ byPosition.set(input.target, positions);
668
+ }
669
+ const nextInGroup = new Map();
670
+ const nextPosition = new Map();
671
+ for (const [target, positions] of byPosition) {
672
+ nextInGroup.set(target, new Map());
673
+ nextPosition.set(target, new Map());
674
+ const orderedPositions = [...positions.keys()].sort((a, b) => a - b);
675
+ for (const position of orderedPositions) {
676
+ const group = positions.get(position);
677
+ const stack = [];
678
+ for (let index = 0; index < group.length; index++) {
679
+ while (stack.length && queuedAtOf(group[stack.at(-1)]) <= queuedAtOf(group[index]))
680
+ stack.pop();
681
+ if (stack.length)
682
+ nextInGroup.get(target).set(group[index].message.id, group[stack.at(-1)]);
683
+ stack.push(index);
684
+ }
685
+ }
686
+ for (let index = orderedPositions.length - 1; index >= 0; index--) {
687
+ const position = orderedPositions[index];
688
+ const next = orderedPositions[index + 1];
689
+ nextPosition.get(target).set(position, next);
690
+ }
691
+ }
692
+ const laterFor = (input) => {
693
+ let candidate = nextInGroup.get(input.target)?.get(input.message.id);
694
+ while (candidate && !destinationIds[input.target].has(candidate.message.id))
695
+ candidate = nextInGroup.get(input.target)?.get(candidate.message.id);
696
+ if (candidate)
697
+ return candidate;
698
+ let position = nextPosition.get(input.target)?.get(input.position);
699
+ const queuedAt = queuedAtOf(input);
700
+ while (position !== undefined) {
701
+ const candidate = byPosition
702
+ .get(input.target)
703
+ ?.get(position)
704
+ ?.find((item) => queuedAtOf(item) > queuedAt && destinationIds[input.target].has(item.message.id));
705
+ if (candidate)
706
+ return candidate;
707
+ position = nextPosition.get(input.target)?.get(position);
708
+ }
709
+ return undefined;
710
+ };
711
+ for (const input of ordered) {
712
+ const { message, target } = input;
352
713
  if (message.source.kind !== 'a2a')
353
714
  continue;
354
- const existing = this.pending(run).find((pending) => pending.id === message.id);
355
- if (existing) {
356
- agent.inbox.replace(message.id, message);
357
- const destination = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
358
- if (!destination.some((pending) => pending.id === message.id))
715
+ const destination = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
716
+ if (existingIds.has(message.id)) {
717
+ if (!destinationIds[target].has(message.id)) {
359
718
  this.moveInput(agent, message, target);
719
+ const from = target === 'next-step' ? 'next-turn' : 'next-step';
720
+ destinationIds[from].delete(message.id);
721
+ destinationIds[target].add(message.id);
722
+ }
723
+ continue;
360
724
  }
361
- else
362
- agent.inbox.append(target, message);
725
+ // Keep the recovered public order; only an interrupted earlier input is
726
+ // inserted before the first retained message queued after it.
727
+ const later = laterFor(input);
728
+ const insertAt = later
729
+ ? destination.findIndex((pending) => pending.id === later.message.id)
730
+ : destination.length;
731
+ agent.inbox.splice(target, Math.min(insertAt, destination.length), 0, [message]);
732
+ existingIds.add(message.id);
733
+ destinationIds[target].add(message.id);
363
734
  }
364
735
  if (!snapshots.length)
365
736
  return;
@@ -381,14 +752,21 @@ export class NativeInbox {
381
752
  const { entry } = await this.bridge.ensureTask(randomUUID(), contextId);
382
753
  const run = {
383
754
  entry,
755
+ paused: false,
384
756
  tasks: new Map(),
385
757
  claimed: new Set(),
386
758
  admissions: new Set(),
759
+ commits: new Set(),
387
760
  admissionTail: Promise.resolve(),
388
761
  promotions: new Map(),
389
762
  rolledBack: new Set(),
390
763
  retained: { 'next-step': [], 'next-turn': [] },
391
764
  };
765
+ const pause = [...run.entry.handle.agent.session.snapshotEvents()]
766
+ .reverse()
767
+ .find((event) => event.type === 'agent/inbox/paused');
768
+ if (pause)
769
+ run.paused = pause.data.paused;
392
770
  await this.recoverRetained(run, context);
393
771
  await this.recoverPromotions(run, context);
394
772
  const snapshots = new Map();
@@ -471,8 +849,15 @@ export class NativeInbox {
471
849
  console.error('[dsh-a2a] inbox observer failed:', error);
472
850
  }
473
851
  }
474
- flush(session) {
475
- return this.ctx.parallel('session/flush', session);
852
+ async flush(session) {
853
+ // Core teardown closes handles concurrently; the service-wide barrier drains only live writers.
854
+ if (this.stopping && this.persistence)
855
+ return this.persistence.flush();
856
+ const sessions = this.ctx.get('sessions');
857
+ if (sessions)
858
+ await sessions.flush(session);
859
+ else
860
+ await this.ctx.parallel('session/flush', session);
476
861
  }
477
862
  checkpoint(task, context) {
478
863
  return this.store.checkpoint
@@ -607,6 +992,8 @@ export class NativeInbox {
607
992
  ].includes(task.status.state));
608
993
  }
609
994
  async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
995
+ if (run.paused)
996
+ throw new Error('Inbox is paused.');
610
997
  if (target === 'next-step' && run.active !== task)
611
998
  throw new Error('Steering must address an active task.');
612
999
  const duplicate = () => [...run.tasks.values()].some((owned) => owned.task.history.some((message) => message.messageId === a2aMessage.messageId));
@@ -654,11 +1041,21 @@ export class NativeInbox {
654
1041
  ? history
655
1042
  : [...history, markInboxInputPending(a2aMessage)];
656
1043
  await this.checkpoint({ ...task.task, history: acceptedHistory }, context);
657
- abort.signal.throwIfAborted();
1044
+ // The durable checkpoint is the admission commit point. A later abort may
1045
+ // reject the caller, but must not strand history without its native input.
658
1046
  task.task.history = acceptedHistory;
659
1047
  task.headers.set(message.id, headers);
660
1048
  accepted?.();
1049
+ if (this.stopping && run.entry.borrowed) {
1050
+ if (this.retainPending && !task.discardInput && !run.discardInput)
1051
+ run.retained[target].push(structuredClone(message));
1052
+ return;
1053
+ }
661
1054
  run.entry.handle.agent.send(message, target, wakeup);
1055
+ // Explicit discard markers survive cancel/clear state resets. Disposal
1056
+ // and retain-pending shutdown deliberately leave them unset.
1057
+ if (task.discardInput || run.discardInput)
1058
+ run.entry.handle.agent.inbox.remove(message.id);
662
1059
  this.observeInput('admitted', run, message);
663
1060
  await this.flush(run.entry.handle.agent.session);
664
1061
  void this.ctx
@@ -681,6 +1078,10 @@ export class NativeInbox {
681
1078
  onAbort();
682
1079
  }),
683
1080
  ]);
1081
+ // Admission order follows the abortable wait, but shutdown separately drains
1082
+ // every full commit so an interrupted caller cannot strand durable input.
1083
+ run.commits.add(work);
1084
+ void work.finally(() => run.commits.delete(work)).catch(() => { });
684
1085
  run.admissionTail = admitted.catch(() => { });
685
1086
  try {
686
1087
  await admitted;
@@ -818,6 +1219,8 @@ export class NativeInbox {
818
1219
  const run = this.contexts.get(detail.agent.session.id);
819
1220
  if (!run)
820
1221
  return;
1222
+ if (run.paused)
1223
+ throw new Error('Inbox is paused.');
821
1224
  if (this.stopping)
822
1225
  throw new Error('Inbox is unloading.');
823
1226
  for (let index = detail.messages.length - 1; index >= 0; index--) {
@@ -846,8 +1249,6 @@ export class NativeInbox {
846
1249
  if (run.rolledBack.delete(message.source.promotionId))
847
1250
  detail.messages.splice(index, 1);
848
1251
  }
849
- if (!run.active && detail.messages.some((message) => !this.owner(run, message)))
850
- throw new Error('Native host input requires task-attributed a2aInbox.enqueue().');
851
1252
  // Restarted steering and an ordinary queued turn may be claimed together by the core.
852
1253
  // Requeue other owners before committing any user message, keeping task lifecycles separate.
853
1254
  const owner = detail.messages.find((message) => message.source.kind === 'a2a');
@@ -875,6 +1276,7 @@ export class NativeInbox {
875
1276
  if (run.active && run.active !== task)
876
1277
  throw new Error('A model step cannot consume inputs from different A2A tasks.');
877
1278
  this.select(run, task);
1279
+ task.claimedInputs = detail.messages.filter((candidate) => this.owner(run, candidate) === task);
878
1280
  const selectedAt = task.selectedAt.get(message.id) ?? Date.now();
879
1281
  const timing = { selectedAt, turn: detail.turn, step: detail.step };
880
1282
  try {
@@ -914,6 +1316,12 @@ export class NativeInbox {
914
1316
  return true;
915
1317
  }
916
1318
  const task = run.active;
1319
+ if (event.type === 'turn/end' && run.paused) {
1320
+ // Paused turns must never discard retained input, including a driver
1321
+ // started accidentally before wake(). Requeue before any failure cleanup.
1322
+ this.requeueClaimed(run);
1323
+ return true;
1324
+ }
917
1325
  if (event.type === 'turn/end') {
918
1326
  if (!this.stopping && ['error', 'blocked', 'aborted'].includes(event.data.reason.kind))
919
1327
  for (const owner of run.claimed)
@@ -923,6 +1331,8 @@ export class NativeInbox {
923
1331
  }
924
1332
  if (!task)
925
1333
  return true;
1334
+ if (event.type === 'turn/end')
1335
+ task.claimedInputs = undefined;
926
1336
  if (task.settled) {
927
1337
  if (event.type === 'turn/end')
928
1338
  run.active = undefined;
@@ -1091,9 +1501,10 @@ export class NativeInbox {
1091
1501
  }
1092
1502
  return undefined;
1093
1503
  }
1094
- async cancelTask(run, task) {
1504
+ async cancelTask(run, task, flush = true) {
1095
1505
  const contextId = run.entry.sessionId;
1096
1506
  const taskId = task.task.id;
1507
+ task.discardInput = true;
1097
1508
  this.discard(run, taskId);
1098
1509
  task.controller.abort();
1099
1510
  task.settled = true;
@@ -1105,7 +1516,8 @@ export class NativeInbox {
1105
1516
  }
1106
1517
  try {
1107
1518
  try {
1108
- await this.flush(run.entry.handle.agent.session);
1519
+ if (flush)
1520
+ await this.flush(run.entry.handle.agent.session);
1109
1521
  }
1110
1522
  finally {
1111
1523
  await this.checkpoint(structuredClone(task.task), task.context);
@@ -1116,11 +1528,64 @@ export class NativeInbox {
1116
1528
  task.finish();
1117
1529
  }
1118
1530
  }
1531
+ requeueClaimed(run) {
1532
+ const task = run.active;
1533
+ if (!task?.claimedInputs)
1534
+ return;
1535
+ const committed = new Set(run.entry.handle.agent.session
1536
+ .snapshotEvents()
1537
+ .flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
1538
+ for (const message of [...task.claimedInputs].reverse()) {
1539
+ if (message.source.kind !== 'a2a' || committed.has(message.id))
1540
+ continue;
1541
+ if (!this.pending(run).some((pending) => pending.id === message.id))
1542
+ run.entry.handle.agent.inbox.prepend(message.source.target, message);
1543
+ }
1544
+ task.claimedInputs = undefined;
1545
+ }
1546
+ async pause(contextId) {
1547
+ const run = await this.context(contextId);
1548
+ if (run.paused)
1549
+ return;
1550
+ run.paused = true;
1551
+ run.entry.handle.agent.session.append('agent/inbox/paused', { paused: true });
1552
+ for (const admission of run.admissions)
1553
+ admission.abort();
1554
+ run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
1555
+ await run.entry.handle.agent.whenIdle();
1556
+ this.requeueClaimed(run);
1557
+ }
1558
+ async wake(contextId) {
1559
+ const run = await this.context(contextId);
1560
+ const agent = run.entry.handle.agent;
1561
+ const pending = this.pending(run);
1562
+ run.paused = false;
1563
+ agent.session.append('agent/inbox/paused', { paused: false });
1564
+ const target = agent.inbox.nextTurn.length ? 'next-turn' : 'next-step';
1565
+ const items = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
1566
+ const input = items.at(-1);
1567
+ if (input) {
1568
+ // Agent.send is the public wake seam; reinsert only the tail, without canceling it.
1569
+ agent.session.append('agent/inbox/spliced', {
1570
+ target,
1571
+ start: items.length - 1,
1572
+ removedCount: 1,
1573
+ inserted: [],
1574
+ });
1575
+ agent.send(input, target, true);
1576
+ }
1577
+ const taskIds = [
1578
+ ...new Set(pending.flatMap((input) => (input.source.kind === 'a2a' ? [input.source.taskId] : []))),
1579
+ ];
1580
+ this.observe('woken', run, { taskIds });
1581
+ return taskIds;
1582
+ }
1119
1583
  async clear(contextId) {
1120
1584
  await this.resolving.get(contextId);
1121
1585
  const run = this.contexts.get(contextId);
1122
1586
  if (!run)
1123
1587
  return;
1588
+ run.discardInput = true;
1124
1589
  const taskIds = [
1125
1590
  ...new Set([...run.tasks.values()]
1126
1591
  .filter((task) => !task.settled)
@@ -1165,27 +1630,69 @@ export class NativeInbox {
1165
1630
  this.contexts.delete(session.id);
1166
1631
  }
1167
1632
  shutdown() {
1633
+ this.changes.close();
1168
1634
  if (this.shutdownPromise)
1169
1635
  return this.shutdownPromise;
1170
1636
  this.stopping = true;
1637
+ const failures = [];
1638
+ const borrowed = new Set();
1639
+ const detachBorrowed = () => {
1640
+ for (const run of this.contexts.values()) {
1641
+ if (!run.entry.borrowed)
1642
+ continue;
1643
+ borrowed.add(run);
1644
+ this.contexts.delete(run.entry.sessionId);
1645
+ // Freeze retention before detaching owned input from a still-live native driver.
1646
+ try {
1647
+ const agent = run.entry.handle.agent;
1648
+ if (run.active) {
1649
+ agent.cancel({ kind: 'disposed' }, { keepInbox: true });
1650
+ run.active = undefined;
1651
+ }
1652
+ for (const target of ['next-step', 'next-turn']) {
1653
+ const items = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
1654
+ if (!items.some((message) => message.source.kind === 'a2a'))
1655
+ continue;
1656
+ agent.session.append('agent/inbox/spliced', {
1657
+ target,
1658
+ start: 0,
1659
+ removedCount: items.length,
1660
+ inserted: items.filter((message) => message.source.kind !== 'a2a'),
1661
+ });
1662
+ }
1663
+ }
1664
+ catch (error) {
1665
+ failures.push(error);
1666
+ }
1667
+ }
1668
+ };
1669
+ detachBorrowed();
1171
1670
  this.shutdownPromise = (async () => {
1172
- const failures = [];
1173
1671
  await Promise.allSettled([...this.resolving.values()]);
1174
- const cancellations = [...this.contexts.values()].flatMap((run) => [...run.tasks.values()].flatMap((task) => (task.cancellation ? [task.cancellation] : [])));
1672
+ detachBorrowed();
1673
+ const runs = [...borrowed, ...this.contexts.values()];
1674
+ const cancellations = runs.flatMap((run) => [...run.tasks.values()].flatMap((task) => (task.cancellation ? [task.cancellation] : [])));
1175
1675
  for (const outcome of await Promise.allSettled(cancellations))
1176
1676
  if (outcome.status === 'rejected')
1177
1677
  failures.push(outcome.reason);
1178
- for (const run of this.contexts.values()) {
1678
+ for (const run of runs) {
1179
1679
  for (const admission of run.admissions)
1180
1680
  admission.abort();
1681
+ // An aborted caller may already have settled, but its durable commit can
1682
+ // still be in flight. Finish that work before agent/session disposal.
1683
+ for (const outcome of await Promise.allSettled([...run.commits]))
1684
+ if (outcome.status === 'rejected')
1685
+ failures.push(outcome.reason);
1181
1686
  try {
1182
- run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: this.retainPending });
1687
+ if (!run.entry.borrowed)
1688
+ run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: this.retainPending });
1183
1689
  }
1184
1690
  catch (error) {
1185
1691
  failures.push(error);
1186
1692
  }
1187
1693
  try {
1188
- await run.entry.handle.agent.whenIdle();
1694
+ if (!run.entry.borrowed)
1695
+ await run.entry.handle.agent.whenIdle();
1189
1696
  }
1190
1697
  catch (error) {
1191
1698
  failures.push(error);
@@ -1201,22 +1708,29 @@ export class NativeInbox {
1201
1708
  failures.push(error);
1202
1709
  }
1203
1710
  finally {
1711
+ const targets = ['next-step', 'next-turn'];
1712
+ const committed = this.retainPending
1713
+ ? new Set(run.entry.handle.agent.session
1714
+ .snapshotEvents()
1715
+ .flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])))
1716
+ : new Set();
1717
+ const retainedByTask = new Map();
1718
+ let position = 0;
1719
+ for (const target of targets) {
1720
+ for (const message of run.retained[target]) {
1721
+ const input = { message, target, position: position++ };
1722
+ if (message.source.kind !== 'a2a' || committed.has(message.id))
1723
+ continue;
1724
+ const group = retainedByTask.get(message.source.taskId) ?? [];
1725
+ group.push(input);
1726
+ retainedByTask.set(message.source.taskId, group);
1727
+ }
1728
+ }
1204
1729
  for (const task of run.tasks.values()) {
1205
1730
  try {
1206
1731
  if (!task.settled) {
1207
1732
  if (this.retainPending) {
1208
- const committed = new Set(run.entry.handle.agent.session
1209
- .snapshotEvents()
1210
- .flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
1211
- let position = 0;
1212
- const pending = ['next-step', 'next-turn'].flatMap((target) => run.retained[target].flatMap((message) => {
1213
- const input = { message, target, position: position++ };
1214
- return message.source.kind === 'a2a' &&
1215
- message.source.taskId === task.task.id &&
1216
- !committed.has(message.id)
1217
- ? [input]
1218
- : [];
1219
- }));
1733
+ const pending = retainedByTask.get(task.task.id) ?? [];
1220
1734
  if (pending.length) {
1221
1735
  task.task.metadata = {
1222
1736
  ...task.task.metadata,