@amaster.ai/dsh-a2a 0.1.28 → 0.1.29

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/README.md CHANGED
@@ -20,7 +20,7 @@ Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and
20
20
 
21
21
  The same-process `a2aInbox` service exposes `resolve(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, `promote(contextId, nativeMessageId, activeTaskId)`, and `cancel(taskId)`. There is no separate pause, wake-only, resume, or retained-task restart operation. Cancellation is terminal and removes that task's pending input. Other tasks' input is retained according to official DSH cancellation semantics, and may wait for the next waking message. Clearing a context discards all its pending work.
22
22
 
23
- `a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal follows the official runtime's cancellation behavior. Durable A2A task snapshots require a filesystem, Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
23
+ `a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal cancels tasks by default; `inbox.shutdown: retain-pending` opts into the retention policy below. Durable A2A task snapshots require a filesystem, Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
24
24
 
25
25
  ### Goal rounds
26
26
 
@@ -40,14 +40,29 @@ Only an unclaimed, single-message independent task in the same context can be pr
40
40
 
41
41
  Select `taskStore: filesystem` with `filesystem.root` pointing at a retained directory, and separately enable durable DSH session persistence. Keep queued file materialization on retained storage as well. Each task snapshot is scoped by tenant and user, written to a private temporary file and atomically renamed; no additional service is needed. Use one host writer per directory. Files have no automatic expiry; context clearing deletes task snapshots.
42
42
 
43
- After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or graceful-unload retention beyond the official DSH lifecycle.
43
+ After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or replay of the interrupted active model call.
44
44
 
45
- The dedicated `E2E (dsh-a2a native ownership / issue-75)` integration job runs these regressions with unmodified npm DSH and scripted model responses. Its Langfuse scenario verifies Goal rounds and promoted input under the original task trace, canceled source ownership, and execution restored after SIGKILL. Same-repository CI also sends the scenario to the configured real Langfuse project and polls its Observations API; fork PRs use the local collector without credentials.
45
+ ### Graceful host shutdown
46
+
47
+ Set `inbox: { enabled: true, shutdown: retain-pending }` on both sides of a restart to retain unclaimed A2A `next-step` and `next-turn` input. Keep durable session persistence, the TaskStore and materialized files on retained storage. This is a host configuration policy, not wire metadata. The default `shutdown: cancel` keeps the existing terminal-on-unload behavior.
48
+
49
+ Retention tracks normalized native inbox splices, including public edits, reordering, removals and queue moves, then freezes the final pending state when host/plugin unloading begins. It stops the current execution, checkpoints pending input in task snapshot metadata with its order and original native/A2A identities, then closes live request/subscription streams. The extra checkpoint survives the core's later inbox-clearing disposal. A task with pending input stays nonterminal for recovery; an interrupted task with no pending input becomes failed. Reconnect subscriptions after restart: streams are not replayed or carried across processes.
50
+
51
+ On `a2aInbox.resolve(contextId)`, the plugin restores retained ownership before allowing new input, excludes IDs already committed as `user/message`, and drains the retention checkpoint only after the restored session is durable. Explicit `CancelTask`, `a2aInbox.cancel()` and context clearing remain terminal. Claims already handed to the active execution are not retained, and external tool effects are not exactly-once. The host still owns startup discovery and an attributed waking input; merely resolving a context does not wake it. Persistence/checkpoint errors are reported through the existing shutdown error path; the host must not treat a failed checkpoint as a durable restart barrier.
52
+
53
+ The dedicated `E2E (dsh-a2a graceful retention)` job covers public plugin disposal, a real SIGTERM child process, repeated restarts, promotion/checkpoint races and terminal cancellation. It also reports recovered tasks to Langfuse and verifies their task/turn/generation ancestry through the real Observations API on same-repository CI.
54
+
55
+ ```sh
56
+ node .github/scripts/check-inbox.mjs -t 'graceful retention'
57
+ DSH_GRACEFUL_RETENTION_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'graceful retention.*reports gracefully recovered'
58
+ ```
59
+
60
+ The dedicated `E2E (dsh-a2a native ownership)` integration job runs these regressions with unmodified npm DSH and scripted model responses. Its Langfuse scenario verifies Goal rounds and promoted input under the original task trace, canceled source ownership, and execution restored after SIGKILL. Same-repository CI also sends the scenario to the configured real Langfuse project and polls its Observations API; fork PRs use the local collector without credentials.
46
61
 
47
62
  ```sh
48
- node .github/scripts/check-inbox.mjs -t issue-75
63
+ node .github/scripts/check-inbox.mjs -t 'native ownership'
49
64
  # With LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY configured:
50
- DSH_ISSUE75_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'issue-75.*reports Goal continuation'
65
+ DSH_NATIVE_OWNERSHIP_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'native ownership.*reports Goal continuation'
51
66
  ```
52
67
 
53
68
  ## Configuration
package/lib/inbox.js CHANGED
@@ -5,21 +5,39 @@ import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/s
5
5
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
6
6
  import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
7
7
  export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md';
8
+ const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
8
9
  export class NativeInbox {
9
10
  ctx;
10
11
  bridge;
11
12
  store;
13
+ retainPending;
12
14
  contexts = new Map();
13
15
  resolving = new Map();
14
16
  stopping = false;
15
17
  shutdownPromise;
16
18
  preadmitted = new WeakMap();
17
19
  server;
18
- constructor(ctx, bridge, store) {
20
+ persistence;
21
+ constructor(ctx, bridge, store, retainPending = false) {
19
22
  this.ctx = ctx;
20
23
  this.bridge = bridge;
21
24
  this.store = store;
25
+ this.retainPending = retainPending;
22
26
  bridge.inbox = this;
27
+ this.persistence = ctx.get('sessionPersistence');
28
+ if (retainPending)
29
+ ctx.on('session/event', (session, event) => {
30
+ const run = this.contexts.get(session.id);
31
+ if (!run || event.type !== 'agent/inbox/spliced' || this.stopping || this.unloading())
32
+ return;
33
+ try {
34
+ const splice = event.data;
35
+ run.retained[splice.target].splice(splice.start, splice.removedCount ?? 0, ...structuredClone(splice.inserted));
36
+ }
37
+ catch (error) {
38
+ console.error('[dsh-a2a] failed to track pending inbox:', error);
39
+ }
40
+ });
23
41
  ctx.on('agent/pre-step', async (detail, next) => {
24
42
  await this.prepare(detail);
25
43
  return next();
@@ -73,6 +91,18 @@ export class NativeInbox {
73
91
  }
74
92
  });
75
93
  }
94
+ unloading() {
95
+ // Cordis publishes FiberState as a const enum, without a runtime export.
96
+ const unloading = 5;
97
+ for (let fiber = this.ctx.fiber;;) {
98
+ if (fiber.uid === null || fiber.state === unloading)
99
+ return true;
100
+ const parent = fiber.parent.fiber;
101
+ if (parent === fiber)
102
+ return false;
103
+ fiber = parent;
104
+ }
105
+ }
76
106
  content(message, contextId) {
77
107
  return this.bridge.buildContent(message, contextId);
78
108
  }
@@ -238,9 +268,14 @@ export class NativeInbox {
238
268
  for (const message of event.data.inserted) {
239
269
  if (message.source.kind !== 'a2a' || !message.source.promotedFrom)
240
270
  continue;
271
+ const previous = transfers.get(message.id);
241
272
  transfers.set(message.id, {
242
273
  message: message,
243
- placed: event.data.target === message.source.target,
274
+ // A completed transfer stays complete after later public queue edits.
275
+ placed: event.data.target === message.source.target ||
276
+ !!(previous?.placed &&
277
+ previous.message.source.taskId === message.source.taskId &&
278
+ previous.message.source.promotionId === message.source.promotionId),
244
279
  });
245
280
  }
246
281
  }
@@ -276,6 +311,63 @@ export class NativeInbox {
276
311
  await this.checkpoint(former, context);
277
312
  }
278
313
  }
314
+ async recoverRetained(run, context) {
315
+ if (!this.retainPending)
316
+ return;
317
+ const agent = run.entry.handle.agent;
318
+ const committed = new Set(agent.session
319
+ .snapshotEvents()
320
+ .flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
321
+ const snapshots = [];
322
+ const inputs = [];
323
+ let pageToken = '';
324
+ do {
325
+ const page = await this.store.list({
326
+ tenant: '',
327
+ contextId: agent.session.id,
328
+ pageSize: 100,
329
+ pageToken,
330
+ status: TaskState.TASK_STATE_UNSPECIFIED,
331
+ statusTimestampAfter: undefined,
332
+ }, context);
333
+ for (const task of page.tasks) {
334
+ const retained = task.metadata?.[RETAINED_INPUTS];
335
+ if (typeof retained !== 'string')
336
+ continue;
337
+ snapshots.push(task);
338
+ if (this.terminal(task))
339
+ continue;
340
+ for (const input of JSON.parse(retained)) {
341
+ const { message } = input;
342
+ if (message.source.kind !== 'a2a' || message.source.taskId !== task.id)
343
+ throw new Error('Retained input has invalid task ownership.');
344
+ if (!committed.has(message.id))
345
+ inputs.push(input);
346
+ }
347
+ }
348
+ pageToken = page.nextPageToken;
349
+ } while (pageToken);
350
+ for (const { message, target } of inputs.sort((a, b) => a.position - b.position)) {
351
+ if (message.source.kind !== 'a2a')
352
+ continue;
353
+ const existing = this.pending(run).find((pending) => pending.id === message.id);
354
+ if (existing) {
355
+ agent.inbox.replace(message.id, message);
356
+ const destination = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
357
+ if (!destination.some((pending) => pending.id === message.id))
358
+ this.moveInput(agent, message, target);
359
+ }
360
+ else
361
+ agent.inbox.append(target, message);
362
+ }
363
+ if (!snapshots.length)
364
+ return;
365
+ await this.flush(agent.session);
366
+ for (const task of snapshots) {
367
+ delete task.metadata[RETAINED_INPUTS];
368
+ await this.checkpoint(task, context);
369
+ }
370
+ }
279
371
  context(contextId, context = new ServerCallContext()) {
280
372
  if (this.stopping)
281
373
  return Promise.reject(new Error('Native inbox is unloading.'));
@@ -294,7 +386,9 @@ export class NativeInbox {
294
386
  admissionTail: Promise.resolve(),
295
387
  promotions: new Map(),
296
388
  rolledBack: new Set(),
389
+ retained: { 'next-step': [], 'next-turn': [] },
297
390
  };
391
+ await this.recoverRetained(run, context);
298
392
  await this.recoverPromotions(run, context);
299
393
  const snapshots = new Map();
300
394
  for (const message of this.pending(run)) {
@@ -311,6 +405,10 @@ export class NativeInbox {
311
405
  await this.flush(entry.handle.agent.session);
312
406
  for (const snapshot of snapshots.values())
313
407
  this.restoreTask(run, snapshot, context);
408
+ run.retained = structuredClone({
409
+ 'next-step': [...entry.handle.agent.inbox.nextStep],
410
+ 'next-turn': [...entry.handle.agent.inbox.nextTurn],
411
+ });
314
412
  this.contexts.set(contextId, run);
315
413
  this.observe('resolved', run);
316
414
  for (const message of this.pending(run))
@@ -323,7 +421,9 @@ export class NativeInbox {
323
421
  }
324
422
  observe(phase, run, fields = {}) {
325
423
  try {
326
- const pending = this.pending(run);
424
+ const pending = this.stopping && this.retainPending
425
+ ? [...run.retained['next-step'], ...run.retained['next-turn']]
426
+ : this.pending(run);
327
427
  void this.ctx
328
428
  .parallel('a2a/inbox', {
329
429
  phase,
@@ -627,7 +727,7 @@ export class NativeInbox {
627
727
  bus.publish(task.final);
628
728
  }
629
729
  catch (error) {
630
- if (task?.settled)
730
+ if (task?.settled || (this.stopping && this.retainPending))
631
731
  return;
632
732
  if (task && !ownsTask) {
633
733
  bus.publish(AgentEvent.task(structuredClone(task.task)));
@@ -1004,6 +1104,7 @@ export class NativeInbox {
1004
1104
  ];
1005
1105
  for (const admission of run.admissions)
1006
1106
  admission.abort();
1107
+ run.retained = { 'next-step': [], 'next-turn': [] };
1007
1108
  run.entry.handle.agent.inbox.clear();
1008
1109
  run.entry.handle.agent.cancel({ kind: 'user' });
1009
1110
  await run.entry.handle.agent.whenIdle();
@@ -1020,7 +1121,7 @@ export class NativeInbox {
1020
1121
  }
1021
1122
  onDisposed(session) {
1022
1123
  const run = this.contexts.get(session.id);
1023
- if (!run)
1124
+ if (!run || (this.retainPending && this.stopping))
1024
1125
  return;
1025
1126
  for (const admission of run.admissions)
1026
1127
  admission.abort();
@@ -1053,7 +1154,7 @@ export class NativeInbox {
1053
1154
  for (const admission of run.admissions)
1054
1155
  admission.abort();
1055
1156
  try {
1056
- run.entry.handle.agent.cancel({ kind: 'disposed' });
1157
+ run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: this.retainPending });
1057
1158
  }
1058
1159
  catch (error) {
1059
1160
  failures.push(error);
@@ -1065,7 +1166,11 @@ export class NativeInbox {
1065
1166
  failures.push(error);
1066
1167
  }
1067
1168
  try {
1068
- await this.flush(run.entry.handle.agent.session);
1169
+ // The persistence barrier tolerates writers already closed by parallel core teardown.
1170
+ if (this.retainPending && this.persistence)
1171
+ await this.persistence.flush();
1172
+ else
1173
+ await this.flush(run.entry.handle.agent.session);
1069
1174
  }
1070
1175
  catch (error) {
1071
1176
  failures.push(error);
@@ -1073,8 +1178,40 @@ export class NativeInbox {
1073
1178
  finally {
1074
1179
  for (const task of run.tasks.values()) {
1075
1180
  try {
1076
- if (!task.settled)
1077
- await this.cancelTask(run, task);
1181
+ if (!task.settled) {
1182
+ if (this.retainPending) {
1183
+ const committed = new Set(run.entry.handle.agent.session
1184
+ .snapshotEvents()
1185
+ .flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
1186
+ let position = 0;
1187
+ const pending = ['next-step', 'next-turn'].flatMap((target) => run.retained[target].flatMap((message) => {
1188
+ const input = { message, target, position: position++ };
1189
+ return message.source.kind === 'a2a' &&
1190
+ message.source.taskId === task.task.id &&
1191
+ !committed.has(message.id)
1192
+ ? [input]
1193
+ : [];
1194
+ }));
1195
+ if (pending.length) {
1196
+ task.task.metadata = {
1197
+ ...task.task.metadata,
1198
+ [RETAINED_INPUTS]: JSON.stringify(pending),
1199
+ };
1200
+ await this.checkpoint(structuredClone(task.task), task.context);
1201
+ }
1202
+ else {
1203
+ task.final = terminalStatusUpdate(task.task.id, run.entry.sessionId, 'failed', 'Host stopped; active input will not be replayed.');
1204
+ if (task.final.kind === 'statusUpdate')
1205
+ task.task.status = task.final.data.status;
1206
+ await this.checkpoint(structuredClone(task.task), task.context);
1207
+ task.bus.publish(task.final);
1208
+ }
1209
+ task.settled = true;
1210
+ task.controller.abort();
1211
+ }
1212
+ else
1213
+ await this.cancelTask(run, task);
1214
+ }
1078
1215
  }
1079
1216
  catch (error) {
1080
1217
  failures.push(error);
package/lib/index.js CHANGED
@@ -30,7 +30,10 @@ export const name = 'dsh-a2a';
30
30
  /** The bridge creates and owns agents through the registry service. */
31
31
  export const inject = ['agents'];
32
32
  export const Config = Schema.object({
33
- inbox: Schema.object({ enabled: Schema.boolean().default(false) }).description('Use the official DSH inbox for queued turns and steering.'),
33
+ inbox: Schema.object({
34
+ enabled: Schema.boolean().default(false),
35
+ shutdown: Schema.union([Schema.const('cancel'), Schema.const('retain-pending')]).default('cancel'),
36
+ }).description('Use the official DSH inbox for queued turns and steering.'),
34
37
  enabled: Schema.boolean().default(false),
35
38
  host: Schema.string().default('127.0.0.1'),
36
39
  port: Schema.natural().default(41241),
@@ -114,7 +117,9 @@ export function apply(ctx, config) {
114
117
  subagentTools: config.subagentTools,
115
118
  });
116
119
  const store = createTaskStore(config);
117
- const inbox = config.inbox?.enabled ? new NativeInbox(ctx, bridge, store) : undefined;
120
+ const inbox = config.inbox?.enabled
121
+ ? new NativeInbox(ctx, bridge, store, config.inbox.shutdown === 'retain-pending')
122
+ : undefined;
118
123
  await store.init?.();
119
124
  if (inbox)
120
125
  ctx.provide('a2aInbox', inbox);
@@ -71,13 +71,16 @@ export declare class NativeInbox implements A2aInbox {
71
71
  private readonly ctx;
72
72
  private readonly bridge;
73
73
  private readonly store;
74
+ private readonly retainPending;
74
75
  private readonly contexts;
75
76
  private readonly resolving;
76
77
  private stopping;
77
78
  private shutdownPromise?;
78
79
  private readonly preadmitted;
79
80
  server?: A2aServer;
80
- constructor(ctx: Context, bridge: A2aBridge, store: ManagedTaskStore);
81
+ private readonly persistence?;
82
+ constructor(ctx: Context, bridge: A2aBridge, store: ManagedTaskStore, retainPending?: boolean);
83
+ private unloading;
81
84
  content(message: Message, contextId: string): Promise<ContentBlock[]>;
82
85
  resolve(contextId: string): Promise<Agent>;
83
86
  enqueue(taskId: string, message: Message, options?: {
@@ -89,6 +92,7 @@ export declare class NativeInbox implements A2aInbox {
89
92
  private promoteInput;
90
93
  /** Repair only an incomplete ownership move, never an ordinary DSH claim. */
91
94
  private recoverPromotions;
95
+ private recoverRetained;
92
96
  private context;
93
97
  private observe;
94
98
  private observeInput;
@@ -71,8 +71,10 @@ export declare const inject: string[];
71
71
  export declare const Config: Schema<Schemastery.ObjectS<{
72
72
  inbox: Schema<Schemastery.ObjectS<{
73
73
  enabled: Schema<boolean, boolean>;
74
+ shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
74
75
  }>, Schemastery.ObjectT<{
75
76
  enabled: Schema<boolean, boolean>;
77
+ shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
76
78
  }>>;
77
79
  enabled: Schema<boolean, boolean>;
78
80
  host: Schema<string, string>;
@@ -158,8 +160,10 @@ export declare const Config: Schema<Schemastery.ObjectS<{
158
160
  }>, Schemastery.ObjectT<{
159
161
  inbox: Schema<Schemastery.ObjectS<{
160
162
  enabled: Schema<boolean, boolean>;
163
+ shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
161
164
  }>, Schemastery.ObjectT<{
162
165
  enabled: Schema<boolean, boolean>;
166
+ shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
163
167
  }>>;
164
168
  enabled: Schema<boolean, boolean>;
165
169
  host: Schema<string, string>;
@@ -246,6 +250,7 @@ export declare const Config: Schema<Schemastery.ObjectS<{
246
250
  export interface A2aPluginConfig {
247
251
  inbox?: {
248
252
  enabled: boolean;
253
+ shutdown?: 'cancel' | 'retain-pending';
249
254
  };
250
255
  enabled: boolean;
251
256
  host: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "A2A protocol (JSON-RPC + SSE) server plugin for DeepSeek Harness: expose dsh agents as A2A agents.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -46,7 +46,8 @@
46
46
  "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
47
47
  "@google-cloud/storage": "^7.0.0",
48
48
  "ioredis": "^5.0.0",
49
- "@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
49
+ "@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
50
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
50
51
  },
51
52
  "peerDependenciesMeta": {
52
53
  "@deepseek-ai/dsh-attachment": {
@@ -63,6 +64,9 @@
63
64
  },
64
65
  "@deepseek-ai/dsh-goal": {
65
66
  "optional": true
67
+ },
68
+ "@deepseek-ai/dsh-session-persistence": {
69
+ "optional": true
66
70
  }
67
71
  },
68
72
  "devDependencies": {
@@ -81,7 +85,8 @@
81
85
  "ioredis": "^5.0.0",
82
86
  "typescript": "^5.6.3",
83
87
  "vitest": "^4.1.11",
84
- "@deepseek-ai/dsh-goal": "0.2.1-rc.2"
88
+ "@deepseek-ai/dsh-goal": "0.2.1-rc.2",
89
+ "@deepseek-ai/dsh-session-persistence": "0.2.1-rc.2"
85
90
  },
86
91
  "engines": {
87
92
  "node": "^20.19 || ^22.12 || >=24.0"