@amaster.ai/dsh-a2a 0.1.38 → 0.1.40

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
@@ -5,7 +5,7 @@ import { RequestMalformedError } from '@a2a-js/sdk/errors';
5
5
  import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
6
6
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
7
7
  import { InboxChanges } from './inbox-changes.js';
8
- import { commitInboxInput, markInboxInputPending } from './task-store.js';
8
+ import { commitInboxInput, findRecoveryCandidates, isTerminalTask, markInboxInputPending, taskScopeKey, } from './task-store.js';
9
9
  import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
10
10
  export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/packages/dsh-a2a/README.md';
11
11
  const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
@@ -131,11 +131,14 @@ export class NativeInbox {
131
131
  fiber = parent;
132
132
  }
133
133
  }
134
- content(message, contextId) {
135
- return this.bridge.buildContent(message, contextId);
134
+ content(message, contextId, signal) {
135
+ return this.bridge.buildContent(message, contextId, signal);
136
136
  }
137
- async resolve(contextId) {
138
- return (await this.context(contextId)).entry.handle.agent;
137
+ async resolve(contextId, context) {
138
+ return (await this.context(contextId, context)).entry.handle.agent;
139
+ }
140
+ recoveryCandidates(context = new ServerCallContext()) {
141
+ return findRecoveryCandidates(this.store, context);
139
142
  }
140
143
  async get(contextId, page) {
141
144
  validateInboxPage(page);
@@ -255,7 +258,7 @@ export class NativeInbox {
255
258
  !message.parts.length ||
256
259
  message.parts.some((part) => !part.content))
257
260
  throw new RequestMalformedError('A user Message with unchanged messageId, taskId and contextId is required.');
258
- const content = await this.content(message, contextId);
261
+ const content = await this.content(message, contextId, task.controller.signal);
259
262
  if (this.stopping ||
260
263
  this.bridge.isClearing(contextId) ||
261
264
  this.queued(run, id).message !== queued.message)
@@ -523,7 +526,7 @@ export class NativeInbox {
523
526
  if (this.stopping || this.bridge.isClearing(contextId))
524
527
  throw new Error('Context is unavailable.');
525
528
  const previous = await this.store.load(taskId, context);
526
- if (!previous || previous.contextId !== contextId || !this.terminal(previous))
529
+ if (!previous || previous.contextId !== contextId || !isTerminalTask(previous))
527
530
  throw new Error('Resume requires a terminal retained task.');
528
531
  const retained = retainedSnapshot(previous);
529
532
  if (typeof retained !== 'string')
@@ -658,7 +661,7 @@ export class NativeInbox {
658
661
  if (message.source.promotedFrom === message.source.taskId)
659
662
  continue;
660
663
  const former = await this.store.load(message.source.promotedFrom, context);
661
- if (!former || this.terminal(former))
664
+ if (!former || isTerminalTask(former))
662
665
  continue;
663
666
  former.status = {
664
667
  state: TaskState.TASK_STATE_CANCELED,
@@ -694,7 +697,7 @@ export class NativeInbox {
694
697
  continue;
695
698
  // Terminal snapshots belong to explicit resume(); do not consume their
696
699
  // retained metadata during ordinary context recovery.
697
- if (this.terminal(task))
700
+ if (isTerminalTask(task))
698
701
  continue;
699
702
  snapshots.push(task);
700
703
  for (const input of JSON.parse(retained)) {
@@ -797,18 +800,25 @@ export class NativeInbox {
797
800
  await this.checkpoint(task, context);
798
801
  }
799
802
  }
800
- context(contextId, context = new ServerCallContext()) {
803
+ context(contextId, context) {
801
804
  if (this.stopping)
802
805
  return Promise.reject(new Error('Native inbox is unloading.'));
806
+ const checkScope = (run) => {
807
+ if (context && run.scope !== taskScopeKey(context))
808
+ throw new Error('Context is already loaded in a different tenant or owner scope.');
809
+ return run;
810
+ };
803
811
  const existing = this.contexts.get(contextId);
804
812
  if (existing)
805
- return Promise.resolve(existing);
813
+ return Promise.resolve(existing).then(checkScope);
806
814
  let creating = this.resolving.get(contextId);
807
815
  if (!creating) {
808
816
  creating = (async () => {
817
+ const call = context ?? new ServerCallContext();
809
818
  const { entry } = await this.bridge.ensureTask(randomUUID(), contextId);
810
819
  const run = {
811
820
  entry,
821
+ scope: taskScopeKey(call),
812
822
  paused: false,
813
823
  tasks: new Map(),
814
824
  claimed: new Set(),
@@ -819,23 +829,26 @@ export class NativeInbox {
819
829
  rolledBack: new Set(),
820
830
  retained: { 'next-step': [], 'next-turn': [] },
821
831
  };
822
- await this.recoverRetained(run, context);
823
- await this.recoverPromotions(run, context);
832
+ await this.recoverRetained(run, call);
833
+ await this.recoverPromotions(run, call);
824
834
  const snapshots = new Map();
825
835
  for (const message of this.pending(run)) {
826
836
  if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
827
837
  continue;
828
- const snapshot = await this.store.load(message.source.taskId, context);
838
+ const snapshot = await this.store.load(message.source.taskId, call);
829
839
  if (!snapshot || snapshot.contextId !== contextId)
830
840
  throw new Error('Queued A2A task is not accessible.');
831
- if (this.terminal(snapshot))
841
+ if (isTerminalTask(snapshot))
832
842
  entry.handle.agent.inbox.remove(message.id);
833
843
  else
834
844
  snapshots.set(snapshot.id, snapshot);
835
845
  }
836
846
  await this.flush(entry.handle.agent.session);
847
+ const committed = snapshots.size
848
+ ? this.committedInputs(run, new Set(snapshots.keys()))
849
+ : new Map();
837
850
  for (const snapshot of snapshots.values())
838
- this.restoreTask(run, snapshot, context);
851
+ this.restoreTask(run, snapshot, call, committed.get(snapshot.id) ?? []);
839
852
  run.retained = structuredClone({
840
853
  'next-step': [...entry.handle.agent.inbox.nextStep],
841
854
  'next-turn': [...entry.handle.agent.inbox.nextTurn],
@@ -848,7 +861,7 @@ export class NativeInbox {
848
861
  })().finally(() => this.resolving.delete(contextId));
849
862
  this.resolving.set(contextId, creating);
850
863
  }
851
- return creating;
864
+ return creating.then(checkScope);
852
865
  }
853
866
  observe(phase, run, fields = {}) {
854
867
  try {
@@ -982,7 +995,7 @@ export class NativeInbox {
982
995
  const snapshot = await this.store.load(message.taskId, context);
983
996
  if (!snapshot ||
984
997
  (message.contextId && snapshot.contextId !== message.contextId) ||
985
- this.terminal(snapshot))
998
+ isTerminalTask(snapshot))
986
999
  return;
987
1000
  const run = await this.context(snapshot.contextId, context);
988
1001
  let task = run.tasks.get(snapshot.id);
@@ -1013,7 +1026,7 @@ export class NativeInbox {
1013
1026
  /** Reattach a cold task before the SDK decides whether cancellation has a live executor. */
1014
1027
  async beforeTask(taskId, context) {
1015
1028
  const snapshot = await this.store.load(taskId, context);
1016
- if (!snapshot || this.terminal(snapshot))
1029
+ if (!snapshot || isTerminalTask(snapshot))
1017
1030
  return;
1018
1031
  const run = await this.context(snapshot.contextId, context);
1019
1032
  const task = run.tasks.get(taskId) ?? this.restoreTask(run, snapshot, context);
@@ -1031,15 +1044,6 @@ export class NativeInbox {
1031
1044
  task.status?.state === TaskState.TASK_STATE_REJECTED)
1032
1045
  return 'failed';
1033
1046
  }
1034
- terminal(task) {
1035
- return (!!task.status &&
1036
- [
1037
- TaskState.TASK_STATE_COMPLETED,
1038
- TaskState.TASK_STATE_CANCELED,
1039
- TaskState.TASK_STATE_FAILED,
1040
- TaskState.TASK_STATE_REJECTED,
1041
- ].includes(task.status.state));
1042
- }
1043
1047
  async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
1044
1048
  if (run.paused)
1045
1049
  throw new Error('Inbox is paused.');
@@ -1057,7 +1061,7 @@ export class NativeInbox {
1057
1061
  const headers = context.state.get(STATE_HEADERS_KEY);
1058
1062
  const work = run.admissionTail.then(async () => {
1059
1063
  abort.signal.throwIfAborted();
1060
- const content = await this.content(a2aMessage, run.entry.sessionId);
1064
+ const content = await this.content(a2aMessage, run.entry.sessionId, abort.signal);
1061
1065
  abort.signal.throwIfAborted();
1062
1066
  const message = createUserMessage({
1063
1067
  content,
@@ -1209,13 +1213,23 @@ export class NativeInbox {
1209
1213
  bus.finished();
1210
1214
  }
1211
1215
  }
1212
- restoreTask(run, snapshot, context) {
1216
+ committedInputs(run, taskIds) {
1217
+ const inputs = new Map();
1213
1218
  for (const event of run.entry.handle.agent.session.snapshotEvents()) {
1214
- if (event.type === 'user/message' &&
1215
- event.data.source.kind === 'a2a' &&
1216
- event.data.source.taskId === snapshot.id)
1217
- commitInboxInput(snapshot.history, Message.fromJSON(JSON.parse(event.data.source.message)));
1219
+ if (event.type !== 'user/message' || event.data.source.kind !== 'a2a')
1220
+ continue;
1221
+ const source = event.data.source;
1222
+ if (!taskIds.has(source.taskId))
1223
+ continue;
1224
+ const messages = inputs.get(source.taskId) ?? [];
1225
+ messages.push(Message.fromJSON(JSON.parse(source.message)));
1226
+ inputs.set(source.taskId, messages);
1218
1227
  }
1228
+ return inputs;
1229
+ }
1230
+ restoreTask(run, snapshot, context, committed = this.committedInputs(run, new Set([snapshot.id])).get(snapshot.id) ?? []) {
1231
+ for (const message of committed)
1232
+ commitInboxInput(snapshot.history, message);
1219
1233
  if (!this.server)
1220
1234
  throw new Error('A2A task recovery requires the running server.');
1221
1235
  const bus = this.server.restoreTask(snapshot, context);
@@ -3,7 +3,8 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { Task } from '@a2a-js/sdk';
6
- import { listShells, taskScopeKey } from '../task-store.js';
6
+ import { recoveryContextIds, taskScopeKey } from '../task-store.js';
7
+ import { LIST_PREFIX_BYTES, loadTaskPage, serializeTaskSnapshot, snapshotListShell, taskListShell, } from './listing.js';
7
8
  export class FilesystemTaskStore {
8
9
  root;
9
10
  constructor(config) {
@@ -24,7 +25,7 @@ export class FilesystemTaskStore {
24
25
  try {
25
26
  const file = await open(temporary, 'wx', 0o600);
26
27
  try {
27
- await file.writeFile(JSON.stringify(Task.toJSON(task)));
28
+ await file.writeFile(serializeTaskSnapshot(task, Task.toJSON(task)));
28
29
  await file.sync();
29
30
  }
30
31
  finally {
@@ -59,7 +60,7 @@ export class FilesystemTaskStore {
59
60
  async delete(id, context) {
60
61
  await rm(this.path(id, context), { force: true });
61
62
  }
62
- async list(params, context) {
63
+ async shells(context) {
63
64
  let files;
64
65
  try {
65
66
  files = await readdir(this.directory(context));
@@ -69,18 +70,42 @@ export class FilesystemTaskStore {
69
70
  throw error;
70
71
  files = [];
71
72
  }
72
- const tasks = [];
73
- for (const file of files) {
74
- if (!/^[a-f0-9]{64}\.json$/.test(file))
75
- continue;
76
- try {
77
- tasks.push(Task.fromJSON(JSON.parse(await readFile(join(this.directory(context), file), 'utf8'))));
78
- }
79
- catch (error) {
80
- if (error.code !== 'ENOENT')
81
- throw error;
82
- }
73
+ const snapshots = files.filter((file) => /^[a-f0-9]{64}\.json$/.test(file));
74
+ const shells = [];
75
+ for (let start = 0; start < snapshots.length; start += 16) {
76
+ const batch = await Promise.all(snapshots.slice(start, start + 16).map(async (file) => {
77
+ const path = join(this.directory(context), file);
78
+ try {
79
+ const handle = await open(path, 'r');
80
+ let shell;
81
+ try {
82
+ const prefix = Buffer.alloc(LIST_PREFIX_BYTES);
83
+ const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0);
84
+ shell = snapshotListShell(prefix.toString('utf8', 0, bytesRead));
85
+ // Read legacy or oversized headers through the same open inode.
86
+ if (!shell)
87
+ shell = taskListShell(Task.fromJSON(JSON.parse(await handle.readFile('utf8'))));
88
+ }
89
+ finally {
90
+ await handle.close();
91
+ }
92
+ return shell;
93
+ }
94
+ catch (error) {
95
+ if (error.code !== 'ENOENT')
96
+ throw error;
97
+ }
98
+ }));
99
+ for (const shell of batch)
100
+ if (shell)
101
+ shells.push(shell);
83
102
  }
84
- return listShells(tasks, params);
103
+ return shells;
104
+ }
105
+ async recoveryCandidates(context) {
106
+ return recoveryContextIds(await this.shells(context));
107
+ }
108
+ async list(params, context) {
109
+ return loadTaskPage(await this.shells(context), params, (ids) => Promise.all(ids.map((id) => this.load(id, context))));
85
110
  }
86
111
  }
package/lib/stores/gcs.js CHANGED
@@ -17,8 +17,11 @@ import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { pipeline } from 'node:stream/promises';
19
19
  import { promisify } from 'node:util';
20
- import { gunzipSync, gzipSync } from 'node:zlib';
21
- import { listShells, taskScopeKey } from '../task-store.js';
20
+ import { gunzip, gzip } from 'node:zlib';
21
+ import { recoveryContextIds, taskScopeKey } from '../task-store.js';
22
+ import { loadTaskPage, parseListShell, taskListShell } from './listing.js';
23
+ const compress = promisify(gzip);
24
+ const decompress = promisify(gunzip);
22
25
  export class GcsTaskStore {
23
26
  config;
24
27
  bucket = null;
@@ -35,19 +38,29 @@ export class GcsTaskStore {
35
38
  async save(task, context) {
36
39
  if (!this.bucket)
37
40
  return;
41
+ const shell = JSON.stringify(taskListShell(task));
38
42
  await this.bucket
39
43
  .file(`${this.prefix}/${taskScopeKey(context)}/${task.id}/metadata.json.gz`)
40
- .save(gzipSync(JSON.stringify(task)), { contentType: 'application/gzip', resumable: false });
44
+ .save(await compress(JSON.stringify(task)), {
45
+ contentType: 'application/gzip',
46
+ resumable: false,
47
+ // GCS custom metadata is bounded; oversized identifiers use the legacy read path.
48
+ metadata: { metadata: Buffer.byteLength(shell) <= 4096 ? { dshTaskList: shell } : {} },
49
+ });
41
50
  }
42
51
  async load(taskId, context) {
43
52
  if (!this.bucket)
44
53
  return undefined;
45
54
  const file = this.bucket.file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/metadata.json.gz`);
46
- const [exists] = await file.exists();
47
- if (!exists)
48
- return undefined;
49
- const [buf] = await file.download();
50
- return JSON.parse(gunzipSync(buf).toString('utf8'));
55
+ try {
56
+ const [buf] = await file.download();
57
+ return JSON.parse((await decompress(buf)).toString('utf8'));
58
+ }
59
+ catch (error) {
60
+ if (error.code === 404)
61
+ return undefined;
62
+ throw error;
63
+ }
51
64
  }
52
65
  async delete(taskId, context) {
53
66
  if (!this.bucket)
@@ -61,21 +74,54 @@ export class GcsTaskStore {
61
74
  .delete({ ignoreNotFound: true }),
62
75
  ]);
63
76
  }
64
- async list(params, context) {
77
+ async shells(context) {
65
78
  if (!this.bucket)
66
- return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
79
+ return [];
67
80
  const [files] = await this.bucket.getFiles({
68
81
  prefix: `${this.prefix}/${taskScopeKey(context)}/`,
69
82
  });
70
83
  const shells = [];
71
84
  const metadata = files.filter((file) => file.name.endsWith('/metadata.json.gz'));
72
- // Bound in-flight downloads while avoiding one network round-trip per task.
73
85
  for (let start = 0; start < metadata.length; start += 16) {
74
- const buffers = await Promise.all(metadata.slice(start, start + 16).map((file) => file.download()));
75
- for (const [buf] of buffers)
76
- shells.push(JSON.parse(gunzipSync(buf).toString('utf8')));
86
+ const batch = await Promise.all(metadata.slice(start, start + 16).map(async (file) => {
87
+ const indexed = parseListShell(file.metadata?.metadata?.dshTaskList);
88
+ if (indexed)
89
+ return indexed;
90
+ // Older snapshots have no summary; keep them visible without migration.
91
+ try {
92
+ const [buf] = await file.download();
93
+ return taskListShell(JSON.parse((await decompress(buf)).toString('utf8')));
94
+ }
95
+ catch (error) {
96
+ if (error.code === 404)
97
+ return;
98
+ throw error;
99
+ }
100
+ }));
101
+ for (const shell of batch)
102
+ if (shell)
103
+ shells.push(shell);
77
104
  }
78
- return listShells(shells, params);
105
+ return shells;
106
+ }
107
+ async recoveryCandidates(context) {
108
+ if (!this.bucket)
109
+ throw new Error('[dsh-a2a] GCS TaskStore is not initialized.');
110
+ return recoveryContextIds(await this.shells(context));
111
+ }
112
+ async list(params, context) {
113
+ if (!this.bucket)
114
+ return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
115
+ return loadTaskPage(await this.shells(context), params, (ids) => Promise.all(ids.map(async (id) => {
116
+ try {
117
+ return await this.load(id, context);
118
+ }
119
+ catch (error) {
120
+ if (error.code === 404)
121
+ return;
122
+ throw error;
123
+ }
124
+ })));
79
125
  }
80
126
  /**
81
127
  * Tar + gzip the workspace directory to `tasks/{scope}/{taskId}/workspace.tar.gz`.
@@ -0,0 +1,63 @@
1
+ import { listShells } from '../task-store.js';
2
+ export const LIST_PREFIX_BYTES = 8192;
3
+ const HEADER = '{"_dshTaskList":';
4
+ /** Only the fields needed by listShells; never retain history or status-message content. */
5
+ export function taskListShell(task) {
6
+ return {
7
+ id: task.id,
8
+ contextId: task.contextId,
9
+ status: task.status && {
10
+ state: task.status.state,
11
+ message: undefined,
12
+ timestamp: task.status.timestamp,
13
+ },
14
+ history: [],
15
+ artifacts: [],
16
+ metadata: undefined,
17
+ };
18
+ }
19
+ export function parseListShell(value) {
20
+ if (typeof value !== 'string')
21
+ return;
22
+ try {
23
+ const shell = JSON.parse(value);
24
+ if (!shell ||
25
+ typeof shell.id !== 'string' ||
26
+ typeof shell.contextId !== 'string' ||
27
+ (shell.status !== undefined &&
28
+ (!shell.status ||
29
+ !Number.isInteger(shell.status.state) ||
30
+ (shell.status.timestamp !== undefined && typeof shell.status.timestamp !== 'string'))))
31
+ return;
32
+ return taskListShell(shell);
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ }
38
+ /** A JSON-compatible header shares the snapshot's atomic write, including on older readers. */
39
+ export function serializeTaskSnapshot(task, json = task) {
40
+ return `${HEADER}${JSON.stringify(taskListShell(task))},\n${JSON.stringify(json).slice(1)}`;
41
+ }
42
+ export function snapshotListShell(prefix) {
43
+ if (!prefix.startsWith(HEADER))
44
+ return;
45
+ const newline = prefix.indexOf('\n');
46
+ if (newline < 0 || prefix[newline - 1] !== ',')
47
+ return;
48
+ return parseListShell(prefix.slice(HEADER.length, newline - 1));
49
+ }
50
+ /** Hydrate only the selected page, with bounded I/O and the existing artifact semantics. */
51
+ export async function loadTaskPage(shells, params, load) {
52
+ const page = listShells(shells, params);
53
+ const tasks = [];
54
+ for (let start = 0; start < page.tasks.length; start += 16) {
55
+ const batch = await load(page.tasks.slice(start, start + 16).map((task) => task.id));
56
+ for (const task of batch) {
57
+ // A task can expire or be deleted between enumeration and hydration.
58
+ if (task)
59
+ tasks.push(params.includeArtifacts ? task : { ...task, artifacts: [] });
60
+ }
61
+ }
62
+ return { ...page, tasks };
63
+ }
@@ -6,7 +6,12 @@
6
6
  * snapshots arrive from SanitizedTaskStore at state transitions;
7
7
  * dsh-storage separately owns the full conversation log (ai_messages).
8
8
  */
9
- import { listShells, taskScopeKey } from '../task-store.js';
9
+ import { recoveryContextIds, taskScopeKey } from '../task-store.js';
10
+ import { LIST_PREFIX_BYTES, loadTaskPage, serializeTaskSnapshot, snapshotListShell, taskListShell, } from './listing.js';
11
+ function parseSnapshot(raw) {
12
+ const { _dshTaskList: _, ...task } = JSON.parse(raw);
13
+ return task;
14
+ }
10
15
  export class RedisTaskStore {
11
16
  config;
12
17
  redis = null;
@@ -27,34 +32,72 @@ export class RedisTaskStore {
27
32
  async save(task, context) {
28
33
  if (!this.redis)
29
34
  return;
30
- await this.redis.set(this.taskKey(task.id, context), JSON.stringify(task), 'EX', this.ttl);
35
+ await this.redis.set(this.taskKey(task.id, context), serializeTaskSnapshot(task), 'EX', this.ttl);
31
36
  }
32
37
  async load(taskId, context) {
33
38
  if (!this.redis)
34
39
  return undefined;
35
40
  const raw = await this.redis.get(this.taskKey(taskId, context));
36
- return raw ? JSON.parse(raw) : undefined;
41
+ return raw ? parseSnapshot(raw) : undefined;
37
42
  }
38
43
  async delete(taskId, context) {
39
44
  await this.redis?.del(this.taskKey(taskId, context));
40
45
  }
41
- async list(params, context) {
46
+ async shells(context) {
42
47
  if (!this.redis)
43
- return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
44
- // Task shells are few and TTL-bound — a paged SCAN over the prefix is ample.
48
+ return [];
49
+ // SCAN only names; bounded prefixes supply the lightweight list summaries.
45
50
  const shells = [];
51
+ const seen = new Set();
46
52
  let cursor = '0';
47
53
  do {
48
- const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:${taskScopeKey(context)}:*`, 'COUNT', 100);
54
+ const [next, scanned] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:${taskScopeKey(context)}:*`, 'COUNT', 100);
49
55
  cursor = next;
56
+ // SCAN may repeat keys during rehashing, including within one batch.
57
+ const keys = scanned.filter((key) => {
58
+ if (seen.has(key))
59
+ return false;
60
+ seen.add(key);
61
+ return true;
62
+ });
50
63
  if (keys.length) {
51
- const raws = await this.redis.mget(...keys);
52
- for (const raw of raws)
53
- if (raw)
54
- shells.push(JSON.parse(raw));
64
+ const prefixes = await this.redis
65
+ .pipeline(keys.map((key) => ['getrange', key, 0, LIST_PREFIX_BYTES - 1]))
66
+ .exec();
67
+ const legacy = [];
68
+ prefixes.forEach(([error, prefix], index) => {
69
+ if (error)
70
+ throw error;
71
+ if (!prefix)
72
+ return; // Expired since SCAN.
73
+ const shell = snapshotListShell(prefix);
74
+ if (shell)
75
+ shells.push(shell);
76
+ else
77
+ legacy.push(keys[index]);
78
+ });
79
+ if (legacy.length) {
80
+ const raws = await this.redis.mget(...legacy);
81
+ for (const raw of raws)
82
+ if (raw)
83
+ shells.push(taskListShell(parseSnapshot(raw)));
84
+ }
55
85
  }
56
86
  } while (cursor !== '0');
57
- return listShells(shells, params);
87
+ return shells;
88
+ }
89
+ async recoveryCandidates(context) {
90
+ if (!this.redis)
91
+ throw new Error('[dsh-a2a] Redis TaskStore is not initialized.');
92
+ return recoveryContextIds(await this.shells(context));
93
+ }
94
+ async list(params, context) {
95
+ if (!this.redis)
96
+ return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
97
+ return loadTaskPage(await this.shells(context), params, async (ids) => {
98
+ const raws = await this.redis.mget(...ids.map((id) => this.taskKey(id, context)));
99
+ return raws.map((raw) => (raw ? parseSnapshot(raw) : undefined));
100
+ });
58
101
  }
59
102
  async close() {
60
103
  await this.redis?.quit?.();
package/lib/task-store.js CHANGED
@@ -52,6 +52,40 @@ export function sanitizeTask(task) {
52
52
  ? { ...task, history: task.history.filter((message) => message.messageId !== current) }
53
53
  : task;
54
54
  }
55
+ export function isTerminalTask(task) {
56
+ return [
57
+ TaskState.TASK_STATE_COMPLETED,
58
+ TaskState.TASK_STATE_CANCELED,
59
+ TaskState.TASK_STATE_FAILED,
60
+ TaskState.TASK_STATE_REJECTED,
61
+ ].includes(task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED);
62
+ }
63
+ export function recoveryContextIds(tasks) {
64
+ return [
65
+ ...new Set(tasks.filter((task) => !isTerminalTask(task)).map((task) => task.contextId)),
66
+ ].sort();
67
+ }
68
+ /** Custom stores retain a complete, if more expensive, discovery path. */
69
+ export async function findRecoveryCandidates(store, context) {
70
+ if (store.recoveryCandidates)
71
+ return store.recoveryCandidates(context);
72
+ const ids = new Set();
73
+ let pageToken = '';
74
+ do {
75
+ const page = await store.list({
76
+ tenant: '',
77
+ contextId: '',
78
+ pageSize: 100,
79
+ pageToken,
80
+ status: TaskState.TASK_STATE_UNSPECIFIED,
81
+ statusTimestampAfter: undefined,
82
+ }, context);
83
+ for (const id of recoveryContextIds(page.tasks))
84
+ ids.add(id);
85
+ pageToken = page.nextPageToken;
86
+ } while (pageToken);
87
+ return [...ids].sort();
88
+ }
55
89
  function scopeOf(context) {
56
90
  return JSON.stringify([context?.tenant ?? '', context ? resolveUserScope(context) : 'unknown']);
57
91
  }
@@ -81,6 +115,9 @@ export class MemoryTaskStore {
81
115
  async list(params, context) {
82
116
  return listShells([...this.bucket(context).values()], params);
83
117
  }
118
+ async recoveryCandidates(context) {
119
+ return recoveryContextIds([...this.bucket(context).values()]);
120
+ }
84
121
  async delete(taskId, context) {
85
122
  this.bucket(context).delete(taskId);
86
123
  }
@@ -137,13 +174,7 @@ export class SanitizedTaskStore {
137
174
  this.terminalStates.delete(task.id);
138
175
  else if (terminal && state !== terminal.state)
139
176
  return;
140
- if (state !== undefined &&
141
- [
142
- TaskState.TASK_STATE_COMPLETED,
143
- TaskState.TASK_STATE_CANCELED,
144
- TaskState.TASK_STATE_FAILED,
145
- TaskState.TASK_STATE_REJECTED,
146
- ].includes(state)) {
177
+ if (state !== undefined && isTerminalTask(task)) {
147
178
  // Map order matches expiry order: only the expired prefix needs scanning.
148
179
  for (const [id, guard] of this.terminalStates) {
149
180
  if (guard.expiresAt > now)
@@ -237,6 +268,9 @@ export class SanitizedTaskStore {
237
268
  list(params, context) {
238
269
  return this.inner.list(params, context);
239
270
  }
271
+ recoveryCandidates(context) {
272
+ return findRecoveryCandidates(this.inner, context);
273
+ }
240
274
  }
241
275
  /**
242
276
  * Shared `listTasks` semantics for the backends: filter by contextId / status /
@@ -79,6 +79,7 @@ export declare class A2aBridge {
79
79
  private readonly executing;
80
80
  private readonly asked;
81
81
  private readonly approvals;
82
+ private readonly preparing;
82
83
  private readonly codec;
83
84
  private readonly delegation;
84
85
  private readonly children;
@@ -96,8 +97,8 @@ export declare class A2aBridge {
96
97
  }>;
97
98
  private ensureTaskInner;
98
99
  private createAgent;
99
- /** A2A parts → dsh content blocks; throws only when no part is usable. */
100
- buildContent(message: Message, contextId: string): Promise<ContentBlock[]>;
100
+ /** A2A parts → dsh content blocks; rejects unusable messages and cancellation. */
101
+ buildContent(message: Message, contextId: string, signal?: AbortSignal, taskId?: string): Promise<ContentBlock[]>;
101
102
  isClearing(contextId: string): boolean;
102
103
  /** Called before the SDK starts an executor, so a bad reply cannot fail the live task. */
103
104
  validateApprovalMessage(message: Message): boolean;
@@ -26,6 +26,7 @@ export interface A2aFileMaterializer {
26
26
  bytes: Uint8Array;
27
27
  filename: string;
28
28
  mediaType: string;
29
+ signal?: AbortSignal;
29
30
  }): Promise<{
30
31
  readablePath: string;
31
32
  }>;
@@ -40,7 +41,7 @@ export interface UrlDownloadOptions {
40
41
  /** Private/loopback hosts are refused unless explicitly named here. */
41
42
  allowedHosts?: readonly string[];
42
43
  }
43
- export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization, downloads?: UrlDownloadOptions): Promise<ContentBlock[]>;
44
+ export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization, downloads?: UrlDownloadOptions, signal?: AbortSignal): Promise<ContentBlock[]>;
44
45
  /** The directory non-image file parts persist into without a materializer. */
45
46
  export declare function uploadsDir(root?: string): string;
46
47
  export {};