dsh-plugin-subagent-bridge 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/index.js +42 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -111,7 +111,9 @@ codex mcp remove dsh-bridge
111
111
  | `maxHops` | `1` | 跨工具委派层数上限(1 = 只允许一层,和 Codex / DSH 的默认 `maxDepth: 1` 一致) |
112
112
  | `dispatchPerMinute` | `10` | 滚动窗口内允许的派发次数(spawn / task / dispatch / followup / codex_dispatch 合计) |
113
113
  | `dispatchWindowMs` | `60000` | 上述窗口长度 |
114
- | `maxWorkers` | `4` | 同时打开的 worker 上限;**已完成但未 `dsh_close` 的也占名额**(与 Codex 一致) |
114
+ | `maxWorkersPerRoot` | `4` | **每个工作目录**的 worker 上限(`cwd` 自动分桶,调用方不需要传 owner) |
115
+ | `maxWorkers` | `12` | 全局兜底上限,防止多个项目一起把机器压满 |
116
+ | `workerTtlMs` | `86400000` | 记录超过这个时间没更新就不再占名额(过期自动释放,无需手动清理) |
115
117
  | `waitTimeoutMs` | `30000` | `dsh_wait` 的默认超时(1s ~ 1h,客户端可用 `timeoutMs` 覆盖) |
116
118
  | `readWaitSeconds` | `25` | `dsh_read_result` 的默认长轮询时长(上限 55,客户端可用 `waitSeconds` 覆盖) |
117
119
  | `codexBin` | `codex` | Codex 可执行文件;含 `/` 时按路径严格解析,找不到就报错而不回退 |
@@ -146,6 +148,18 @@ codex mcp remove dsh-bridge
146
148
  rate limit: 10 dispatches per 60s reached; retry in 23s
147
149
  ```
148
150
 
151
+ ## 名额怎么算
152
+
153
+ worker 名额**按工作目录分桶**,调用方不用传任何 owner:
154
+
155
+ - 每个 `cwd` 默认 **4 个**(`maxWorkersPerRoot`);
156
+ - 全局默认 **12 个**(`maxWorkers`),跨项目兜底;
157
+ - **完成但没 `dsh_close` 的仍占名额**(与 Codex 一致);
158
+ - 记录 **24 小时**没更新就自动不再占名额(`workerTtlMs`),不需要手动清理;
159
+ - 阻塞式 `dsh_task` 不计入名额,只登记。
160
+
161
+ 为什么不是"每个会话 4 个":MCP 端点是共享的,请求里没有会话或线程身份(Codex 的 `CODEX_THREAD_ID` 只存在于 shell 环境)。`cwd` 是唯一由调用方稳定提供、且语义上对应"一个项目"的维度。
162
+
149
163
  ## 跨重启
150
164
 
151
165
  worker 注册表(`task_name` / `sessionId` / 终态结果)和每轮的时间基线都落在 `~/.dsh/bridge/workers.json`:
@@ -219,7 +233,7 @@ mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-codex ~/.dsh/skills/
219
233
  node --test "test/*.test.mjs"
220
234
  ```
221
235
 
222
- 52 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 HTTP 往返(含 404/405/202 与 bearer 分支)、`codex exec` 事件解析、二进制解析、成功/失败/超时三种运行结果、resume 的参数分叉、工具结果的 lossless JSON 约束、**只回最终回答**(多步叙述被丢弃)、`dsh_wait` 的任一完成 / 超时 / 错误 / 未派发四种状态、`dsh_spawn` 立即返回与注册、`fork_turns=all` 走 fork、并发上限与 `dsh_close` 释放名额、`dsh_followup` 的重基线与最终回答切换、注册表落盘与重启后复原、冷会话下的缓存回答与 `offline` 状态、注册表 200 条上限的淘汰顺序、`maxHops` 拒绝回派而放行首层、在飞链路阻塞嵌套派发、速率突发的拒绝与窗口过期、followup 计入同一预算、握手版本号取自 `package.json`(不再硬编码)、阻塞式 `dsh_task` 不占 spawn 名额。
236
+ 55 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 HTTP 往返(含 404/405/202 与 bearer 分支)、`codex exec` 事件解析、二进制解析、成功/失败/超时三种运行结果、resume 的参数分叉、工具结果的 lossless JSON 约束、**只回最终回答**(多步叙述被丢弃)、`dsh_wait` 的任一完成 / 超时 / 错误 / 未派发四种状态、`dsh_spawn` 立即返回与注册、`fork_turns=all` 走 fork、并发上限与 `dsh_close` 释放名额、`dsh_followup` 的重基线与最终回答切换、注册表落盘与重启后复原、冷会话下的缓存回答与 `offline` 状态、注册表 200 条上限的淘汰顺序、`maxHops` 拒绝回派而放行首层、在飞链路阻塞嵌套派发、速率突发的拒绝与窗口过期、followup 计入同一预算、握手版本号取自 `package.json`(不再硬编码)、阻塞式 `dsh_task` 不占 spawn 名额(重启后也不占)、按 `cwd` 分桶的每项目预算与全局上限、过期记录到期释放名额。
223
237
  `test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
224
238
 
225
239
  ---
package/index.js CHANGED
@@ -66,7 +66,9 @@ const DEFAULTS = {
66
66
  codexTimeoutMs: 300000,
67
67
  taskTimeoutSeconds: 300,
68
68
  waitTimeoutMs: 30000,
69
- maxWorkers: 4,
69
+ maxWorkers: 12,
70
+ maxWorkersPerRoot: 4,
71
+ workerTtlMs: 24 * 60 * 60 * 1000,
70
72
  maxHops: 1,
71
73
  dispatchPerMinute: 10,
72
74
  dispatchWindowMs: 60000,
@@ -590,9 +592,10 @@ export function createApi(ctx, state, settings) {
590
592
  const service = controller()
591
593
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
592
594
  if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
595
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : forkTurns === 'all' ? `fork:${String(from)}` : '(default)'
593
596
  guardDepth(state, settings, 0, 'dsh_spawn')
594
597
  takeDispatchSlot(state, settings)
595
- assertCapacity(state, settings)
598
+ assertCapacity(state, settings, root)
596
599
 
597
600
  let sessionId
598
601
  if (forkTurns === 'all') {
@@ -617,7 +620,15 @@ export function createApi(ctx, state, settings) {
617
620
  )
618
621
  state.dispatched.add(sessionId)
619
622
  state.baseline.set(sessionId, before)
620
- state.workers.set(taskName, { taskName, sessionId, status: 'open', countsTowardLimit: true, createdAt: Date.now(), updatedAt: Date.now() })
623
+ state.workers.set(taskName, {
624
+ taskName,
625
+ sessionId,
626
+ status: 'open',
627
+ countsTowardLimit: true,
628
+ root,
629
+ createdAt: Date.now(),
630
+ updatedAt: Date.now(),
631
+ })
621
632
  await saveWorkers(settings, state)
622
633
  return { task_name: taskName, sessionId, accepted: accepted?.accepted === true, fork_turns: forkTurns }
623
634
  },
@@ -660,7 +671,7 @@ export function createApi(ctx, state, settings) {
660
671
  for (const record of state.workers.values()) {
661
672
  const status = record.status === 'closed' ? 'closed' : workerStatus(ctx, record.sessionId, state, settings)
662
673
  if (record.status !== 'closed') await rememberOutcome(settings, state, record.sessionId, status)
663
- rows.push({ task_name: record.taskName, sessionId: record.sessionId, status })
674
+ rows.push({ task_name: record.taskName, sessionId: record.sessionId, root: record.root ?? '(default)', status })
664
675
  }
665
676
  return rows
666
677
  },
@@ -703,6 +714,7 @@ export function createApi(ctx, state, settings) {
703
714
  sessionId: targetId,
704
715
  status: 'open',
705
716
  countsTowardLimit: false,
717
+ root: typeof cwd === 'string' && cwd !== '' ? cwd : '(default)',
706
718
  createdAt: Date.now(),
707
719
  updatedAt: Date.now(),
708
720
  })
@@ -988,6 +1000,9 @@ export async function loadWorkers(settings, state) {
988
1000
  taskName: entry.taskName,
989
1001
  sessionId: entry.sessionId,
990
1002
  status: entry.status === 'closed' ? 'closed' : 'open',
1003
+ /* Blocking tasks resume as non-counting records, exactly as they were written. */
1004
+ countsTowardLimit: entry.countsTowardLimit !== false,
1005
+ ...(typeof entry.root === 'string' ? { root: entry.root } : {}),
991
1006
  createdAt: Number(entry.createdAt ?? Date.now()),
992
1007
  updatedAt: Number(entry.updatedAt ?? Date.now()),
993
1008
  ...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
@@ -1071,16 +1086,26 @@ function resolveWorker(state, reference) {
1071
1086
  * @param state - worker bookkeeping.
1072
1087
  * @param settings - resolved settings.
1073
1088
  */
1074
- function assertCapacity(state, settings) {
1075
- let open = 0
1089
+ function assertCapacity(state, settings, root) {
1090
+ const now = Date.now()
1091
+ let total = 0
1092
+ let inRoot = 0
1076
1093
  for (const record of state.workers.values()) {
1077
1094
  if (record.status === 'closed') continue
1078
1095
  if (record.countsTowardLimit === false) continue
1079
- open += 1
1096
+ /* A record nobody has touched for a whole TTL stops holding a slot. */
1097
+ if (now - Number(record.updatedAt ?? record.createdAt ?? 0) > settings.workerTtlMs) continue
1098
+ total += 1
1099
+ if (record.root === root) inRoot += 1
1100
+ }
1101
+ if (inRoot >= settings.maxWorkersPerRoot) {
1102
+ throw new Error(
1103
+ `worker limit reached for ${root} (${settings.maxWorkersPerRoot}); close one with dsh_close before spawning another`,
1104
+ )
1080
1105
  }
1081
- if (open >= settings.maxWorkers) {
1106
+ if (total >= settings.maxWorkers) {
1082
1107
  throw new Error(
1083
- `worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1108
+ `global worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1084
1109
  )
1085
1110
  }
1086
1111
  }
@@ -1565,6 +1590,14 @@ function resolveSettings(config) {
1565
1590
  const ms = Number(value)
1566
1591
  if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
1567
1592
  }
1593
+ if (key === 'maxWorkersPerRoot') {
1594
+ const count = Number(value)
1595
+ if (Number.isInteger(count) && count > 0) merged.maxWorkersPerRoot = count
1596
+ }
1597
+ if (key === 'workerTtlMs') {
1598
+ const ms = Number(value)
1599
+ if (Number.isFinite(ms) && ms > 0) merged.workerTtlMs = ms
1600
+ }
1568
1601
  if (key === 'maxWorkers') {
1569
1602
  const count = Number(value)
1570
1603
  if (Number.isInteger(count) && count > 0) merged.maxWorkers = count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Subagent bridge: lets Codex delegate a task to DeepSeek Harness the way it would spawn a subagent (one call, waits for the answer), plus a codex_dispatch tool for the reverse direction.",
6
6
  "keywords": [