@ai_mind/dsh-a2a 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -278,12 +278,48 @@ A2A 创建的 Harness 会话使用 `agent.cwd` 作为工作目录,GUI 按会
278
278
 
279
279
  ### 会话与并发
280
280
 
281
- - **一个 `contextId` = 一个 Harness Agent 会话**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
281
+ - **同一租户、认证主体和 `contextId` 对应一个持久化 Harness Session**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
282
282
  - 创建 Agent 时会挂载 `agent.preset` 指定的 Agent 预设(默认取部署的默认预设),因此 A2A Agent 拥有与 GUI 会话一致的工具与提示词组合,再叠加下面的沙箱与审批策略。
283
- - 空闲 context 在 `agent.contextIdleTtlMs`(默认 15 分钟)后被回收,Agent 会话随之销毁。
283
+ - 空闲 context 在 `agent.contextIdleTtlMs`(默认 15 分钟)后释放运行中的 Agent;保留绑定与历史。下次通过 `ctx.agents.resume({ resumeSessionId })` 恢复同一 Session,服务重启后也一样。绑定不随任务 TTL 或空闲 TTL 删除。
284
284
  - 全局并发上限 `agent.maxConcurrentTasks`(默认 4);超出的任务最多等待 `agent.queueTimeoutMs`,仍无法开始时进入 `TASK_STATE_REJECTED`。
285
285
  - 单回合上限 `agent.turnTimeoutMs`(默认 15 分钟);超时会取消该回合并返回 `TASK_STATE_FAILED`。
286
286
 
287
+ ### 持久化部署与升级
288
+
289
+ 运行时要求 Harness 的 `agents.resume` 与持久化 `sessionPersistence.flush` API。缺少服务时任务失败,不降级到易失会话。创建 Session 和完成每轮后使用公共 `flush()` 确认落盘;该 API 是服务级屏障,会刷新同一 Harness 中其他活跃 Session,因此磁盘故障可能同时影响多个任务。
290
+
291
+ ```yaml
292
+ agent:
293
+ contextStorePath: /var/lib/dsh-a2a/context-bindings
294
+ ```
295
+
296
+ - 绑定目录默认是 `<解析后的 agent.cwd>/.a2a-contexts`。生产应显式指定固定的绝对持久路径,独立于工作目录、发布目录和临时容器。修改 cwd 或切换挂载点时,不可遗漏原绑定目录。
297
+ - **绑定目录与 Harness Session 持久化目录必须一起保留/备份**。绑定记录使用原子创建、文件/目录 fsync,目录默认权限 `0700`、文件 `0600`;文件名是 scope 与 context 的哈希,不保存 token 或对话正文。需要支持硬链接和 fsync 的本地文件系统。
298
+ - 认证主体采用服务端 `auth.clients[].id`(或配置推导出的凭据 ID),再与 SDK 的 tenant、认证状态一起隔离。同一凭据下不同终端可恢复同一 context;不同用户不可共享同一个 client ID。轮换 token 时保留 client ID;重排 `auth.tokens` 可能改变自动生成的主体 ID,建议使用命名 clients。`auth.mode: none` 下所有匿名调用共享匿名主体,不提供用户隔离。
299
+ - 同一运行时内同 scope/context 串行;创建、排队和存储刷新期间不做空闲释放。不同进程/运行时竞争同一 Session 时依赖 Harness 的独占写所有权,冲突请求失败并可在原持有者释放后重试,不进行分布式排队。多副本须共享同一绑定及 Session 后端且保证后端所有权语义,不能各挂一套独立目录。
300
+ - 绑定已存在但损坏、Session 缺失、存储不可用、preset 无法挂载或恢复失败时,任务进入 `TASK_STATE_FAILED`,`status.message.parts[].text` 包含 `A2A_HISTORY_UNAVAILABLE`。不会自动删除绑定、替换 Session 或清空历史。首次绑定落盘后、首次 Session 创建前崩溃,也遵守此规则;保留该预留记录而不是猜测它未承载过历史。
301
+ - 故障恢复:先停止对应流量并备份两个目录,恢复丢失的 Session 后以原 context 重试。若能确认某预留记录从未成功创建 Session,可由维护者通过 Harness 在记录的**相同 Session ID** 下创建空 Session(使用记录中的 cwd/preset),然后重试。无法确认时不删除/改写绑定;保留记录,明确告知用户原历史不可恢复,由用户另开新对话。当前没有自动修复/重置接口。
302
+ - 绑定没有自动保留期。运维清理需按会话删除策略协调两份存储;单独删绑定会让服务器把再次到来的 context 视为首次使用,不能靠 Session ID 自动重建它。
303
+
304
+ **旧版本迁移边界:** 旧代码只在内存记录 context,随机 Session ID 的 metadata 只有 cwd/preset,没有 context 或认证主体。已回收或进程已退出的旧映射无法自动恢复;即使原 Session 日志还在,也不能靠内容猜测归属。若升级前能在可信运行时/日志中取得精确的 `(tenant, authenticated, client ID, contextId) → Session ID`,且已验证 Session 所有权和日志完整,维护者可离线导入 v1 绑定(格式见 `lib/context-bindings.js`,键通过其 `contextKey()` 生成),保留原 cwd/preset。没有该证据的旧对话只能从新映射开始,**不承诺自动继承旧历史**。应在升级前向用户说明,避免把新建空 Session 误称为历史恢复。
305
+
306
+ ### 手机与其他 A2A 客户端契约
307
+
308
+ 保持 A2A 1.0 `SendMessage`/`SendStreamingMessage` 契约:同一对话复用 contextId、每条消息使用新 messageId、仅发送本轮内容;普通任务完成后创建新 task,只有协议允许续接的非终态 task 才传旧 taskId。服务端通过 Session 事件恢复模型输入,不要求客户端回传整段历史。切换认证主体或 tenant 会进入隔离会话;客户端应持久化 contextId 及其 endpoint/身份归属。
309
+
310
+ 历史恢复失败时保持原 contextId,不自动重置或以历史全文重放。建议显示失败状态中的 `A2A_HISTORY_UNAVAILABLE` 提示并让用户重试/联系维护者。这不新增请求字段;现有手机版本可继续使用。服务端 task 查询仍是有限保留的内存存储,Session 恢复不意味着旧 task 在重启后可查询。
311
+
312
+ ### 本地恢复验证
313
+
314
+ ```sh
315
+ npm test
316
+ DSH_NODE_MODULES="$HOME/.dsh/profiles/node_modules" npm run test:harness
317
+ # 从 ai-mind-mobile 仓库执行:
318
+ DSH_NODE_MODULES="$HOME/.dsh/profiles/node_modules" npm --prefix ../ai-mind-dsh-a2a run test:harness
319
+ ```
320
+
321
+ `DSH_NODE_MODULES` 应指向待部署 Harness 的实际 node_modules,缺省会明确报错,不静默跳过。本机验证使用 Node 25.8.0、Harness `0.2.1-rc.2`。测试在临时目录构建真实 Cordis、AgentLoop、Session 和 JSONL 存储;只有模型适配器是无状态暗号回忆 oracle。它从真正传入适配器的 `request.messages` 查找随机标记,不持有上轮记忆。检查连续追问、TTL、整套 Harness 重建、真实子进程直接退出、主体/租户隔离、排队/回收竞争、重复写所有权及历史缺失。断言包含 Session ID、用户与助手历史和回忆输出;不需要模型凭据,不读取在线会话,不等同于实际云端模型效果或线上事故归因。
322
+
287
323
  ### 提示词与附件
288
324
 
289
325
  - `text` 部分拼接为提示词;`data` 部分以 JSON 代码块注入;`url` 部分以引用形式注入。
@@ -415,6 +451,7 @@ agent:
415
451
  queueTimeoutMs: 60000
416
452
  turnTimeoutMs: 900000
417
453
  contextIdleTtlMs: 900000
454
+ contextStorePath: null # 绝对持久目录;默认 <cwd>/.a2a-contexts;生产务必固定路径
418
455
 
419
456
  limits:
420
457
  maxRequestBytes: 8388608
package/lib/bridge.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * DeepSeek Harness agent bridge for A2A execution.
3
3
  *
4
- * One A2A `contextId` maps to one long-lived Harness agent session, so several
4
+ * One scoped A2A `contextId` durably maps to one Harness session, so several
5
5
  * single-turn A2A tasks that share a context keep their conversation state.
6
6
  * Turns inside a context run serially; distinct contexts run concurrently up
7
7
  * to the configured limit. Idle contexts are disposed after
8
- * `agent.contextIdleTtlMs` so a busy A2A endpoint cannot leak sessions.
8
+ * `agent.contextIdleTtlMs`; the next turn resumes their persisted history.
9
9
  *
10
10
  * The bridge deliberately touches only public Harness services (`ctx.agents`,
11
11
  * `ctx.approval`) and the documented `Agent` handle, never package internals.
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  import { mkdir } from 'node:fs/promises'
17
+ import { join } from 'node:path'
17
18
  import { newId, errorMessage } from './util.js'
18
19
  import { withTimeout } from './limits.js'
20
+ import { ContextBindings, contextKey } from './context-bindings.js'
19
21
 
20
22
  /** Marker used as the durable source of prompts injected by this plugin. */
21
23
  const PROMPT_SOURCE = { kind: 'user' }
@@ -112,10 +114,14 @@ class ContextWorker {
112
114
  #lastUsed = Date.now()
113
115
  #creating = undefined
114
116
  #disposed = false
117
+ #pending = 0
118
+ #disposal
119
+ #predecessor
115
120
 
116
- constructor(runtime, contextId) {
121
+ constructor(runtime, contextId, predecessor) {
117
122
  this.#runtime = runtime
118
123
  this.#contextId = contextId
124
+ this.#predecessor = predecessor
119
125
  }
120
126
 
121
127
  get contextId() {
@@ -131,7 +137,7 @@ class ContextWorker {
131
137
  }
132
138
 
133
139
  get busy() {
134
- return this.#turn !== undefined
140
+ return this.#pending > 0
135
141
  }
136
142
 
137
143
  touch() {
@@ -151,6 +157,7 @@ class ContextWorker {
151
157
  * @returns {Promise<{ canceled: boolean, error?: Error, text: string, turnEnd?: object }>}
152
158
  */
153
159
  async runTurn(turn, { prompt, timeoutMs }) {
160
+ this.#pending += 1
154
161
  const previous = this.#tail
155
162
  let release
156
163
  this.#tail = new Promise((resolveTail) => {
@@ -162,6 +169,7 @@ class ContextWorker {
162
169
  if (turn.cancelRequested) return { canceled: true, text: '' }
163
170
  return await this.#runNow(turn, prompt, timeoutMs)
164
171
  } finally {
172
+ this.#pending -= 1
165
173
  release()
166
174
  this.touch()
167
175
  }
@@ -170,7 +178,7 @@ class ContextWorker {
170
178
  async #runNow(turn, prompt, timeoutMs) {
171
179
  this.touch()
172
180
  const agent = await this.#ensureAgent()
173
- if (turn.cancelRequested) return { canceled: true, text: '' }
181
+ if (turn.cancelRequested || this.#disposed) return { canceled: true, text: '' }
174
182
 
175
183
  this.#turn = turn
176
184
  turn.running = true
@@ -204,6 +212,12 @@ class ContextWorker {
204
212
  if (turn.cancelRequested) {
205
213
  return { canceled: true, text: turn.finalText || turn.emitted, turnEnd: turn.turnEnd }
206
214
  }
215
+ // whenIdle is a loop barrier, not a storage durability barrier.
216
+ try {
217
+ await this.#runtime.persistence().flush()
218
+ } catch (error) {
219
+ turn.error = new Error(`A2A_HISTORY_UNAVAILABLE: cannot persist conversation: ${errorMessage(error)}`)
220
+ }
207
221
  const failure = turn.error ?? turnEndError(turn.turnEnd)
208
222
  const canceledByAgent =
209
223
  turn.turnEnd?.kind === 'aborted' && (turn.turnEnd.reason?.kind === 'user' || turn.turnEnd.reason?.kind === 'parent')
@@ -270,41 +284,54 @@ class ContextWorker {
270
284
  async #createAgent() {
271
285
  const runtime = this.#runtime
272
286
  const config = runtime.config
273
- const cwd = await runtime.resolveCwd()
274
- if (typeof cwd !== 'string' || !cwd.startsWith('/')) {
275
- throw new Error(`A2A agent cwd must be an absolute path, got ${JSON.stringify(cwd)}`)
287
+ await this.#predecessor
288
+ runtime.persistence()
289
+ if (typeof runtime.ctx.agents.resume !== 'function') {
290
+ throw new Error('A2A_HISTORY_UNAVAILABLE: Harness agents.resume is required')
276
291
  }
277
- await mkdir(cwd, { recursive: true })
278
- const workspace = await runtime.ensureWorkspace(cwd)
279
- const sessionId = `session-a2a-${newId()}`
280
292
  const agentOptions = runtime.agentOptions()
293
+ const bindings = await runtime.bindings()
281
294
  const presets = runtime.ctx.get?.('agentPresets')
282
- let presetId
283
- if (presets !== undefined) {
284
- try {
285
- presetId = (await presets.resolve(runtime.config.agent.preset ?? undefined)).id
286
- } catch (error) {
287
- throw new Error(
288
- `cannot resolve the agent preset ${JSON.stringify(runtime.config.agent.preset ?? 'default')}: ${errorMessage(error)}`,
289
- )
290
- }
295
+ const { binding, fresh } = await bindings.reserve(this.#contextId, async () => {
296
+ const cwd = await runtime.resolveCwd()
297
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) throw new Error('A2A agent cwd must be an absolute path')
298
+ await mkdir(cwd, { recursive: true })
299
+ let presetId
300
+ if (presets !== undefined) presetId = (await presets.resolve(config.agent.preset ?? undefined)).id
301
+ return { sessionId: `session-a2a-${newId()}`, cwd, presetId: presetId ?? null }
302
+ })
303
+ const { sessionId, cwd, presetId } = binding
304
+ if (presetId !== null && presets === undefined) {
305
+ throw new Error('A2A_HISTORY_UNAVAILABLE: original agent preset service is unavailable')
291
306
  }
307
+ const workspace = await runtime.ensureWorkspace(cwd)
292
308
  let handle
293
309
  try {
294
- handle = await runtime.ctx.agents.create({
295
- sessionId,
296
- meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
310
+ handle = await runtime.ctx.agents[fresh ? 'create' : 'resume']({
311
+ ...(fresh
312
+ ? { sessionId, meta: { cwd, ...(presetId === null ? {} : { agentPreset: presetId }) } }
313
+ : { resumeSessionId: sessionId }),
297
314
  ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
298
315
  setup: async (agentCtx, agent) => {
299
316
  this.#installListeners(agentCtx, agent)
300
317
  // Mounting the preset composes the same tools, prompt sections and
301
318
  // hooks a GUI session receives; without it an A2A agent would run
302
319
  // with only the root composition's bare tool set.
303
- if (presets !== undefined && presetId !== undefined) await presets.mount(agentCtx, presetId)
320
+ if (presets !== undefined && presetId !== null) await presets.mount(agentCtx, presetId)
304
321
  },
305
322
  })
306
323
  } catch (error) {
307
- throw new Error(`cannot create a Harness agent for A2A context ${this.#contextId}: ${errorMessage(error)}`)
324
+ throw new Error(`A2A_HISTORY_UNAVAILABLE: cannot ${fresh ? 'create' : 'resume'} Harness session ${sessionId}: ${errorMessage(error)}`)
325
+ }
326
+ // Never acknowledge a session until it is durably materialized.
327
+ try {
328
+ if (handle.agent.id !== sessionId || handle.agent.session.id !== sessionId) {
329
+ throw new Error('Harness returned a different session identity')
330
+ }
331
+ await runtime.persistence().flush()
332
+ } catch (error) {
333
+ await handle.dispose().catch(() => {})
334
+ throw new Error(`A2A_HISTORY_UNAVAILABLE: ${errorMessage(error)}`)
308
335
  }
309
336
  this.#handle = handle
310
337
  this.#agent = handle.agent
@@ -321,7 +348,7 @@ class ContextWorker {
321
348
  runtime.logger.warn(`[a2a] could not apply agent policy: ${errorMessage(error)}`)
322
349
  }
323
350
  runtime.logger.info(
324
- `[a2a] created agent session ${handle.agent.id} for context ${this.#contextId} ` +
351
+ `[a2a] ${fresh ? 'created' : 'resumed'} agent session ${handle.agent.id} for context ${this.#contextId} ` +
325
352
  `(cwd=${cwd}, preset=${presetId ?? 'none'}, sandbox=${config.agent.sandboxMode}, approval=${config.agent.approvalPolicy})`,
326
353
  )
327
354
  return handle.agent
@@ -344,19 +371,19 @@ class ContextWorker {
344
371
  }
345
372
  }
346
373
 
347
- async dispose() {
348
- if (this.#disposed) return
374
+ dispose() {
375
+ if (this.#disposal) return this.#disposal
349
376
  this.#disposed = true
350
- const handle = this.#handle
351
- this.#handle = undefined
352
- this.#agent = undefined
353
- if (handle === undefined) return
354
- try {
355
- await handle.dispose()
356
- this.#runtime.logger.info(`[a2a] disposed agent session for context ${this.#contextId}`)
357
- } catch (error) {
358
- this.#runtime.logger.warn(`[a2a] agent dispose failed for context ${this.#contextId}: ${errorMessage(error)}`)
359
- }
377
+ if (this.#turn) this.cancel(this.#turn)
378
+ this.#disposal = (async () => {
379
+ // Includes queued work and in-flight factory setup, which busy used to miss.
380
+ await this.#tail
381
+ const handle = this.#handle
382
+ this.#handle = undefined
383
+ this.#agent = undefined
384
+ if (handle) await handle.dispose()
385
+ })()
386
+ return this.#disposal
360
387
  }
361
388
  }
362
389
 
@@ -370,6 +397,7 @@ export class AgentRuntime {
370
397
  #workers = new Map()
371
398
  #sweepTimer = undefined
372
399
  #workspacePromise = undefined
400
+ #bindingsPromise
373
401
  #disposed = false
374
402
  #stats = { turns: 0, completed: 0, failed: 0, canceled: 0 }
375
403
 
@@ -397,6 +425,20 @@ export class AgentRuntime {
397
425
  return this.#logger
398
426
  }
399
427
 
428
+ persistence() {
429
+ const persistence = this.#ctx.get?.('sessionPersistence')
430
+ if (typeof persistence?.flush !== 'function') {
431
+ throw new Error('A2A_HISTORY_UNAVAILABLE: durable Harness sessionPersistence is required')
432
+ }
433
+ return persistence
434
+ }
435
+
436
+ bindings() {
437
+ return this.#bindingsPromise ??= (async () => new ContextBindings(
438
+ this.#config.agent.contextStorePath ?? join(await this.resolveCwd(), '.a2a-contexts'),
439
+ ))()
440
+ }
441
+
400
442
  /**
401
443
  * Resolve the model route for a new A2A agent.
402
444
  *
@@ -509,25 +551,29 @@ export class AgentRuntime {
509
551
  if (now - worker.lastUsed < ttl) continue
510
552
  stale.push(worker)
511
553
  }
554
+ let disposed = 0
512
555
  for (const worker of stale) {
513
- this.#workers.delete(worker.contextId)
556
+ if (worker.disposed || worker.busy || now - worker.lastUsed < ttl) continue
514
557
  await worker.dispose()
558
+ disposed += 1
559
+ if (this.#workers.get(worker.contextId) === worker) this.#workers.delete(worker.contextId)
515
560
  }
516
- return stale.length
561
+ return disposed
517
562
  }
518
563
 
519
564
  /**
520
565
  * Get or create the worker for one context.
521
566
  *
522
567
  * @param {string} contextId
568
+ * @param {object} [callContext] SDK-authenticated owner and tenant (never message metadata)
523
569
  * @returns {ContextWorker}
524
570
  */
525
- acquire(contextId) {
571
+ acquire(contextId, callContext) {
526
572
  if (this.#disposed) throw new Error('A2A runtime is shutting down')
527
- const key = contextId === '' || contextId === undefined ? `context-${newId()}` : contextId
573
+ const key = contextKey(contextId === '' || contextId === undefined ? `context-${newId()}` : contextId, callContext)
528
574
  let worker = this.#workers.get(key)
529
575
  if (worker === undefined || worker.disposed) {
530
- worker = new ContextWorker(this, key)
576
+ worker = new ContextWorker(this, key, worker?.dispose())
531
577
  this.#workers.set(key, worker)
532
578
  }
533
579
  worker.touch()
@@ -558,6 +604,9 @@ export class AgentRuntime {
558
604
  }
559
605
  const workers = [...this.#workers.values()]
560
606
  this.#workers.clear()
561
- await Promise.allSettled(workers.map((worker) => worker.dispose()))
607
+ const results = await Promise.allSettled(workers.map((worker) => worker.dispose()))
608
+ for (const result of results) {
609
+ if (result.status === 'rejected') this.#logger.warn(`[a2a] agent dispose failed: ${errorMessage(result.reason)}`)
610
+ }
562
611
  }
563
612
  }
package/lib/config.js CHANGED
@@ -103,6 +103,7 @@ export const DEFAULT_CONFIG = {
103
103
  queueTimeoutMs: 60000,
104
104
  turnTimeoutMs: 900000,
105
105
  contextIdleTtlMs: 900000,
106
+ contextStorePath: null,
106
107
  },
107
108
  limits: {
108
109
  maxRequestBytes: 8 * 1024 * 1024,
@@ -464,6 +465,10 @@ export function normalizeConfig(raw) {
464
465
  if (merged.agent.cwd !== null && !merged.agent.cwd.startsWith('/')) {
465
466
  fail('agent.cwd', 'must be an absolute path')
466
467
  }
468
+ merged.agent.contextStorePath = expectNullableString(merged.agent.contextStorePath, 'agent.contextStorePath')
469
+ if (merged.agent.contextStorePath !== null && !merged.agent.contextStorePath.startsWith('/')) {
470
+ fail('agent.contextStorePath', 'must be an absolute path')
471
+ }
467
472
  merged.agent.workspaceTitle = expectNullableString(merged.agent.workspaceTitle, 'agent.workspaceTitle', {
468
473
  allowEmpty: false,
469
474
  })
@@ -0,0 +1,54 @@
1
+ /** Durable, immutable context -> Harness session identities. No TTL: TTL only evicts live agents. */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { mkdir, open, readFile, link, unlink } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+
6
+ export function contextKey(contextId, context) {
7
+ // Length-delimited encoding prevents separator collisions. Never trust message metadata.
8
+ return createHash('sha256').update(JSON.stringify([
9
+ context?.tenant ?? '', context?.user?.isAuthenticated === true,
10
+ context?.user?.userName ?? '', contextId,
11
+ ])).digest('hex')
12
+ }
13
+
14
+ export class ContextBindings {
15
+ constructor(directory) { this.directory = directory }
16
+
17
+ async reserve(key, create) {
18
+ const path = join(this.directory, `${key}.json`)
19
+ const read = async () => {
20
+ const binding = JSON.parse(await readFile(path, 'utf8'))
21
+ if (binding.version !== 1 || binding.key !== key ||
22
+ !/^session-a2a-[a-zA-Z0-9-]+$/.test(binding.sessionId) ||
23
+ typeof binding.cwd !== 'string' || !binding.cwd.startsWith('/') ||
24
+ !(binding.presetId === null || typeof binding.presetId === 'string')) {
25
+ throw new Error('invalid context binding')
26
+ }
27
+ return { binding, fresh: false }
28
+ }
29
+ try {
30
+ try { return await read() } catch (error) { if (error.code !== 'ENOENT') throw error }
31
+ await mkdir(this.directory, { recursive: true, mode: 0o700 })
32
+ const binding = { ...await create(), version: 1, key }
33
+ const temporary = join(this.directory, `.${key}-${randomUUID()}.tmp`)
34
+ try {
35
+ const file = await open(temporary, 'wx', 0o600)
36
+ try {
37
+ await file.writeFile(JSON.stringify(binding) + '\n')
38
+ await file.sync()
39
+ } finally { await file.close() }
40
+ // Atomic create-if-absent, including competing runtimes/processes. A loser
41
+ // must resume the winner's ID; Harness enforces exclusive session ownership.
42
+ try { await link(temporary, path) } catch (error) {
43
+ if (error.code === 'EEXIST') return await read()
44
+ throw error
45
+ }
46
+ const directory = await open(this.directory, 'r')
47
+ try { await directory.sync() } finally { await directory.close() }
48
+ return { binding, fresh: true }
49
+ } finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
50
+ } catch (error) {
51
+ throw new Error(`A2A_HISTORY_UNAVAILABLE: context binding cannot be loaded or stored: ${error.message}`, { cause: error })
52
+ }
53
+ }
54
+ }
package/lib/executor.js CHANGED
@@ -227,7 +227,7 @@ export class HarnessAgentExecutor {
227
227
  return
228
228
  }
229
229
 
230
- const worker = this.#runtime.acquire(contextId)
230
+ const worker = this.#runtime.acquire(contextId, requestContext.context)
231
231
  state.worker = worker
232
232
  const turn = worker.createTurn(taskId, {
233
233
  onStart: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai_mind/dsh-a2a",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Production A2A (Agent2Agent) v1.0 server for DeepSeek Harness agents: JSON-RPC + HTTP+JSON bindings, streaming, tasks, push notifications and signed Agent Cards.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,8 @@
48
48
  "express": "^5.1.0"
49
49
  },
50
50
  "scripts": {
51
- "test": "node --test test/*.test.js"
51
+ "test": "node --test test/*.test.js",
52
+ "test:harness": "node --test test/integration/*.test.js"
52
53
  },
53
54
  "engines": {
54
55
  "node": ">=20"