lark-remote 0.5.3 → 0.5.5

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 (66) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/codex/app-server/translator.js +21 -7
  25. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  26. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  27. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  28. package/dist/runner/common/acp/protocol-types.js +3 -1
  29. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  30. package/dist/runner/common/connection-based-runner.js +40 -5
  31. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  32. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  33. package/dist/runner/dsh/approval.d.ts +53 -0
  34. package/dist/runner/dsh/approval.js +161 -0
  35. package/dist/runner/dsh/auth.d.ts +36 -0
  36. package/dist/runner/dsh/auth.js +118 -0
  37. package/dist/runner/dsh/client.d.ts +161 -42
  38. package/dist/runner/dsh/client.js +551 -244
  39. package/dist/runner/dsh/daemon.d.ts +85 -0
  40. package/dist/runner/dsh/daemon.js +272 -0
  41. package/dist/runner/dsh/merge.d.ts +9 -0
  42. package/dist/runner/dsh/merge.js +51 -0
  43. package/dist/runner/dsh/runner.d.ts +94 -11
  44. package/dist/runner/dsh/runner.js +553 -67
  45. package/dist/runner/dsh/translator.d.ts +43 -3
  46. package/dist/runner/dsh/translator.js +115 -38
  47. package/dist/runner/dsh/types.d.ts +189 -27
  48. package/dist/runner/dsh/types.js +11 -5
  49. package/dist/runner/kimi/acp/runner.js +15 -3
  50. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  51. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  52. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  53. package/dist/runner/opencode/acp/runner.js +40 -1
  54. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  55. package/dist/runner/opencode/acp/translator.js +12 -3
  56. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  57. package/dist/runner/pi/rpc/translator.js +27 -1
  58. package/dist/runner/types.d.ts +34 -0
  59. package/dist/runner/types.js +24 -0
  60. package/dist/session/codex/rollout-reader.js +44 -7
  61. package/dist/session/dsh/sessions.d.ts +45 -6
  62. package/dist/session/dsh/sessions.js +171 -27
  63. package/dist/session/opencode/sessions.d.ts +18 -7
  64. package/dist/session/opencode/sessions.js +294 -15
  65. package/dist/session/pi/sessions.js +6 -2
  66. package/package.json +3 -1
@@ -20,9 +20,44 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
20
20
  // 那时 this 不可用,所以只能等 super() 返回后再赋值。
21
21
  connectionManager.stopper = ({ client }) => this.buildCooperativeStop(client);
22
22
  }
23
+ /**
24
+ * session/new|resume retry policy. Default: no retry. Agents whose ACP
25
+ * server sits behind a separately-starting service (opencode v2) override
26
+ * this to absorb a transient `ServiceUnavailableError` without failing the
27
+ * whole turn.
28
+ */
29
+ sessionSetupRetryDelaysMs() {
30
+ return [];
31
+ }
32
+ /** Whether a failed session/new|resume is safe to retry. */
33
+ isRetryableSessionSetupError(_err) {
34
+ return false;
35
+ }
36
+ /** Final error surfaced to the user after retries are exhausted. */
37
+ enrichSessionSetupError(err) {
38
+ return err instanceof Error ? err : new Error(String(err));
39
+ }
23
40
  // =========================================================================
24
41
  // Shared plumbing
25
42
  // =========================================================================
43
+ async withSessionSetupRetry(fn) {
44
+ const delays = this.sessionSetupRetryDelaysMs();
45
+ for (let attempt = 0;; attempt++) {
46
+ try {
47
+ return await fn();
48
+ }
49
+ catch (err) {
50
+ if (this.stopRequested ||
51
+ attempt >= delays.length ||
52
+ !this.isRetryableSessionSetupError(err)) {
53
+ throw this.enrichSessionSetupError(err);
54
+ }
55
+ const delay = delays[attempt];
56
+ getLogger().warn(`[${this.logTag}] session setup failed (attempt ${attempt + 1}/${delays.length + 1}), retrying in ${delay}ms: ${err.message}`);
57
+ await new Promise((resolve) => setTimeout(resolve, delay));
58
+ }
59
+ }
60
+ }
26
61
  currentSessionId() {
27
62
  return this.activeSessionId;
28
63
  }
@@ -132,14 +167,31 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
132
167
  * session/resume (existing sessionId) → applyTurnSettings → session/prompt.
133
168
  */
134
169
  async setupTurn(message, opts) {
170
+ // 本轮回调归属锚点:代际 + client 都不可变。旧轮迟到的 prompt
171
+ // 结果/通知在新轮开始时 stopRequested 已被重置,只有这两个值能识别它。
172
+ const generation = this.turnGeneration;
135
173
  const client = await this.connectionManager.acquire(opts.cwd);
136
174
  this.connectionManager.notifyActivity(opts.cwd);
137
175
  this.currentClient = client;
138
176
  this.onConnectionAcquired(opts);
139
177
  client.setHooks({
140
- onNotification: (method, params) => this.handleNotification(method, params),
141
- onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
178
+ onNotification: (method, params) => {
179
+ if (!this.isCurrentTurn(generation, client)) {
180
+ getLogger().debug(`[${this.logTag}] dropped notification from a finished turn`);
181
+ return;
182
+ }
183
+ this.handleNotification(method, params);
184
+ },
185
+ onServerRequest: (id, method, params) => {
186
+ if (!this.isCurrentTurn(generation, client)) {
187
+ getLogger().debug(`[${this.logTag}] dropped server request from a finished turn`);
188
+ return;
189
+ }
190
+ this.handleServerRequest(id, method, params);
191
+ },
142
192
  onClose: () => {
193
+ if (!this.isCurrentTurn(generation, client))
194
+ return;
143
195
  getLogger().warn(`[${this.logTag}] client connection closed`);
144
196
  this.onConnectionClosed();
145
197
  this.failTurn(this.connectionClosedMessage);
@@ -151,13 +203,13 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
151
203
  sessionId: opts.sessionId,
152
204
  cwd: opts.cwd,
153
205
  };
154
- const resumeResult = await client.request('session/resume', resumeParams);
206
+ const resumeResult = await this.withSessionSetupRetry(() => client.request('session/resume', resumeParams));
155
207
  this.onSessionEstablished(resumeResult);
156
208
  sessionId = opts.sessionId;
157
209
  }
158
210
  else {
159
211
  const newParams = { cwd: opts.cwd, mcpServers: [] };
160
- const newResult = await client.request('session/new', newParams);
212
+ const newResult = await this.withSessionSetupRetry(() => client.request('session/new', newParams));
161
213
  this.onSessionEstablished(newResult);
162
214
  sessionId = newResult.sessionId;
163
215
  }
@@ -181,7 +233,19 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
181
233
  this.promptSettled = false;
182
234
  const promptPromise = client.request('session/prompt', promptParams, Number.POSITIVE_INFINITY);
183
235
  this.promptSent = true;
184
- promptPromise.then((result) => this.handlePromptResolved(result, translator, sessionId), (err) => this.handlePromptRejected(err, translator, sessionId, ''));
236
+ promptPromise.then((result) => {
237
+ if (!this.isCurrentTurn(generation, client)) {
238
+ getLogger().debug(`[${this.logTag}] dropped late prompt result from a finished turn`);
239
+ return;
240
+ }
241
+ this.handlePromptResolved(result, translator, sessionId);
242
+ }, (err) => {
243
+ if (!this.isCurrentTurn(generation, client)) {
244
+ getLogger().debug(`[${this.logTag}] dropped late prompt rejection from a finished turn`);
245
+ return;
246
+ }
247
+ this.handlePromptRejected(err, translator, sessionId, '');
248
+ });
185
249
  }
186
250
  /**
187
251
  * Run a compact operation on the current session: resume → '/compact'
@@ -189,14 +253,25 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
189
253
  */
190
254
  async *runCompact(_message, opts) {
191
255
  yield* this.executeTurn(opts, async () => {
256
+ const generation = this.turnGeneration;
192
257
  const client = await this.connectionManager.acquire(opts.cwd);
193
258
  this.connectionManager.notifyActivity(opts.cwd);
194
259
  this.currentClient = client;
195
260
  this.onConnectionAcquired(opts);
196
261
  client.setHooks({
197
- onNotification: (method, params) => this.handleNotification(method, params),
198
- onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
262
+ onNotification: (method, params) => {
263
+ if (!this.isCurrentTurn(generation, client))
264
+ return;
265
+ this.handleNotification(method, params);
266
+ },
267
+ onServerRequest: (id, method, params) => {
268
+ if (!this.isCurrentTurn(generation, client))
269
+ return;
270
+ this.handleServerRequest(id, method, params);
271
+ },
199
272
  onClose: () => {
273
+ if (!this.isCurrentTurn(generation, client))
274
+ return;
200
275
  this.onConnectionClosed();
201
276
  this.failTurn(this.connectionClosedMessage);
202
277
  },
@@ -211,7 +286,7 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
211
286
  // memory. On a reused connection the session is already loaded —
212
287
  // resume is idempotent and harmless.
213
288
  const resumeParams = { sessionId, cwd: opts.cwd };
214
- const resumeResult = await client.request('session/resume', resumeParams);
289
+ const resumeResult = await this.withSessionSetupRetry(() => client.request('session/resume', resumeParams));
215
290
  this.onSessionEstablished(resumeResult);
216
291
  const translator = this.createTranslator();
217
292
  translator.setOperationKind('compact');
@@ -13,7 +13,9 @@
13
13
  * lark-remote 内部事件流对 ACP 系 agent(kimi acp、后续 opencode acp)的约定:
14
14
  *
15
15
  * 1. `assistant/text` 与 `assistant/thinking` 是**增量通道**:run-state 收到后
16
- * 向对应 item **追加**文本。
16
+ * 向对应 item **逐字追加**文本,绝不插入分隔符。thinking 必须携带 `itemId`
17
+ * (wire 的 `messageId` 等块身份):同一 itemId = 同一思考块的下一段 delta,
18
+ * itemId 变化才开新块。块生产者(pi)自己决定块内段落分隔。
17
19
  * 2. `turn_diff` 是**快照通道**:run-state 按 itemId **原地替换**全文(kimi ACP
18
20
  * 文本/思考固定 itemId `'text'`/`'thinking'`)。
19
21
  * 3. kimi wire 的 `content.text`(agent_message_chunk / agent_thought_chunk)
@@ -135,7 +137,13 @@ export interface AgentMessageChunkEvent {
135
137
  }
136
138
  export interface AgentThoughtChunkEvent {
137
139
  sessionUpdate: 'agent_thought_chunk';
138
- /** events-map.ts:280-288 — same content.text structure. */
140
+ /**
141
+ * 思考块身份。opencode acp/event.ts 用
142
+ * `${assistantMessageID}:reasoning:${ordinal}` 标识每个 reasoning part;
143
+ * 同 id 的连续 chunk 是同一块的 delta(loadSession 回放时该通道发整块全文)。
144
+ */
145
+ messageId?: string;
146
+ /** events-map.ts:280-288 — same content.text structure (delta, not snapshot). */
139
147
  content: {
140
148
  type: 'text';
141
149
  text: string;
@@ -13,7 +13,9 @@
13
13
  * lark-remote 内部事件流对 ACP 系 agent(kimi acp、后续 opencode acp)的约定:
14
14
  *
15
15
  * 1. `assistant/text` 与 `assistant/thinking` 是**增量通道**:run-state 收到后
16
- * 向对应 item **追加**文本。
16
+ * 向对应 item **逐字追加**文本,绝不插入分隔符。thinking 必须携带 `itemId`
17
+ * (wire 的 `messageId` 等块身份):同一 itemId = 同一思考块的下一段 delta,
18
+ * itemId 变化才开新块。块生产者(pi)自己决定块内段落分隔。
17
19
  * 2. `turn_diff` 是**快照通道**:run-state 按 itemId **原地替换**全文(kimi ACP
18
20
  * 文本/思考固定 itemId `'text'`/`'thinking'`)。
19
21
  * 3. kimi wire 的 `content.text`(agent_message_chunk / agent_thought_chunk)
@@ -25,9 +25,19 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
25
25
  protected lastEventAt: number;
26
26
  protected _isRunning: boolean;
27
27
  protected readonly turnIdleTimeoutMs: number;
28
+ /**
29
+ * 单调递增的轮次代际。每轮 executeTurn 开始
30
+ * 自增;该轮安装的所有回调(prompt settle / notification / server request)
31
+ * 捕获当轮代际 + client,事件落地前校验归属。`_isRunning` / `stopRequested`
32
+ * 是可变跨轮字段,重置于下一轮开始——旧轮迟到的回调会在新轮看到
33
+ * `stopRequested === false` 并通过唯一检查,把旧 session 的 result 灌进新轮。
34
+ */
35
+ protected turnGeneration: number;
28
36
  /** Notification queue consumed by the active run generator. */
29
37
  protected notificationQueue: TEvent[];
30
38
  protected waitResolve: (() => void) | null;
39
+ /** 当前 waitForEvents 的兜底定时器(唤醒时必须清掉)。 */
40
+ private waitTimer;
31
41
  protected forceFinish: boolean;
32
42
  /** stop() 在 turn/start 或 prompt 响应前到达时置位,完成后再补发 cancel/interrupt。 */
33
43
  protected stopRequested: boolean;
@@ -126,6 +136,11 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
126
136
  protected waitForEvents(deadline: number): Promise<boolean>;
127
137
  protected pushEvents(events: TEvent[]): void;
128
138
  protected wakeWaiters(): void;
139
+ /**
140
+ * 归属校验:事件/回调是否仍属于当前轮次。旧轮迟到的 prompt 结果、通知、
141
+ * server request 一律只记 debug,不得进入共享 notificationQueue。
142
+ */
143
+ protected isCurrentTurn(generation: number, client: TClient | null): boolean;
129
144
  protected failTurn(message: string): void;
130
145
  protected resetRunState(): void;
131
146
  /** Release the connection + detach stale client hooks after a lost connection. */
@@ -16,9 +16,19 @@ export class ConnectionBasedRunner {
16
16
  lastEventAt = 0;
17
17
  _isRunning = false;
18
18
  turnIdleTimeoutMs;
19
+ /**
20
+ * 单调递增的轮次代际。每轮 executeTurn 开始
21
+ * 自增;该轮安装的所有回调(prompt settle / notification / server request)
22
+ * 捕获当轮代际 + client,事件落地前校验归属。`_isRunning` / `stopRequested`
23
+ * 是可变跨轮字段,重置于下一轮开始——旧轮迟到的回调会在新轮看到
24
+ * `stopRequested === false` 并通过唯一检查,把旧 session 的 result 灌进新轮。
25
+ */
26
+ turnGeneration = 0;
19
27
  /** Notification queue consumed by the active run generator. */
20
28
  notificationQueue = [];
21
29
  waitResolve = null;
30
+ /** 当前 waitForEvents 的兜底定时器(唤醒时必须清掉)。 */
31
+ waitTimer = null;
22
32
  forceFinish = false;
23
33
  /** stop() 在 turn/start 或 prompt 响应前到达时置位,完成后再补发 cancel/interrupt。 */
24
34
  stopRequested = false;
@@ -85,6 +95,8 @@ export class ConnectionBasedRunner {
85
95
  throw new Error(`${this.constructor.name} is already running`);
86
96
  }
87
97
  this._isRunning = true;
98
+ // 代际先于 setup 自增:setupTurn 里安装的回调捕获到的就是本轮代际。
99
+ this.turnGeneration++;
88
100
  this.resetRunState();
89
101
  try {
90
102
  try {
@@ -271,13 +283,24 @@ export class ConnectionBasedRunner {
271
283
  if (remaining <= 0)
272
284
  return true;
273
285
  await new Promise((resolve) => {
274
- this.waitResolve = resolve;
275
- setTimeout(() => {
276
- if (this.waitResolve === resolve) {
286
+ // 唤醒与超时必须互斥清理:wakeWaiters() 提前 resolve 时要 clearTimeout,
287
+ // 否则高频事件会让 timer 在最长 30s 内持续堆积。
288
+ const waiter = () => {
289
+ if (this.waitTimer === timer) {
290
+ clearTimeout(timer);
291
+ this.waitTimer = null;
292
+ }
293
+ resolve();
294
+ };
295
+ const timer = setTimeout(() => {
296
+ if (this.waitResolve === waiter) {
277
297
  this.waitResolve = null;
298
+ this.waitTimer = null;
278
299
  resolve();
279
300
  }
280
301
  }, Math.min(remaining, 30_000));
302
+ this.waitResolve = waiter;
303
+ this.waitTimer = timer;
281
304
  });
282
305
  }
283
306
  return false;
@@ -290,12 +313,20 @@ export class ConnectionBasedRunner {
290
313
  this.wakeWaiters();
291
314
  }
292
315
  wakeWaiters() {
293
- if (this.waitResolve) {
294
- const resolve = this.waitResolve;
316
+ const resolve = this.waitResolve;
317
+ if (resolve) {
318
+ // waiter 自己会 clearTimeout + 置空 waitTimer。
295
319
  this.waitResolve = null;
296
320
  resolve();
297
321
  }
298
322
  }
323
+ /**
324
+ * 归属校验:事件/回调是否仍属于当前轮次。旧轮迟到的 prompt 结果、通知、
325
+ * server request 一律只记 debug,不得进入共享 notificationQueue。
326
+ */
327
+ isCurrentTurn(generation, client) {
328
+ return this._isRunning && generation === this.turnGeneration && client === this.currentClient;
329
+ }
299
330
  failTurn(message) {
300
331
  this.pushEvents([
301
332
  {
@@ -309,6 +340,10 @@ export class ConnectionBasedRunner {
309
340
  resetRunState() {
310
341
  this.notificationQueue = [];
311
342
  this.waitResolve = null;
343
+ if (this.waitTimer !== null) {
344
+ clearTimeout(this.waitTimer);
345
+ this.waitTimer = null;
346
+ }
312
347
  this.lastEventAt = Date.now();
313
348
  this.forceFinish = false;
314
349
  }
@@ -78,6 +78,8 @@ export interface ConnectionManagerOptions<TClient extends ConnectionClient = Jso
78
78
  logTag?: string;
79
79
  /** 协议停止通道注册表注入(测试隔离);默认进程级单例,透传给 transport。 */
80
80
  stoppers?: AgentStopperRegistry;
81
+ /** Called once after a fresh connection's initialize handshake resolves. */
82
+ onConnected?: (result: unknown, cwd: string) => void;
81
83
  }
82
84
  export declare class ConnectionManager<TClient extends ConnectionClient = JsonRpcClient> {
83
85
  private slots;
@@ -91,6 +93,7 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
91
93
  private readonly shouldReuse;
92
94
  private readonly logTag;
93
95
  private readonly stoppers;
96
+ private readonly onConnected?;
94
97
  /** Callback when a connection is lost — cleared from slot map. */
95
98
  onConnectionLost?: (cwd: string) => void;
96
99
  /**
@@ -112,6 +115,13 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
112
115
  * Creates a new connection if one does not exist or the previous one was lost
113
116
  * (or, for session-bound protocols, bound to a different session).
114
117
  * Serializes creation so concurrent calls share the same connection.
118
+ *
119
+ * 替换旧连接时必须先等 dispose 完成。旧实现
120
+ * `disposeSlot(cwd)` 不 await 就创建新连接,新旧两个 agent server 会重叠;
121
+ * codex 会报 "thread already has an active writer"(同一 workspace 写锁)。
122
+ * 现在的顺序:等旧连接真正 dispose → 重新读 slot(并发 acquire 可能已建好
123
+ * 新连接,直接复用/等它)→ 才进入创建。创建期间 slot 被 release/disposeAll
124
+ * 删掉时,新连接立即 dispose 并重试,不返回无人托管的连接。
115
125
  */
116
126
  acquire(cwd: string, req?: AcquireRequest): Promise<TClient>;
117
127
  /**
@@ -141,5 +151,10 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
141
151
  notifyIdle(cwd: string): void;
142
152
  private createClient;
143
153
  private clearIdleTimer;
154
+ /**
155
+ * Dispose the slot for `cwd`. Returns false when the underlying client's
156
+ * dispose failed — callers replacing a connection must abort the replacement
157
+ * rather than let two agent servers overlap.
158
+ */
144
159
  private disposeSlot;
145
160
  }
@@ -19,6 +19,8 @@ import { getLogger } from '../../../logger/index.js';
19
19
  import { agentStopperRegistry, } from '../../../platform/agent-stopper.js';
20
20
  const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 minutes
21
21
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; // 60 seconds
22
+ /** acquire() 的轮次上限:防止 slot 被持续释放时无限重建连接。 */
23
+ const MAX_ACQUIRE_ATTEMPTS = 5;
22
24
  export class ConnectionManager {
23
25
  slots = new Map();
24
26
  binary;
@@ -31,6 +33,7 @@ export class ConnectionManager {
31
33
  shouldReuse;
32
34
  logTag;
33
35
  stoppers;
36
+ onConnected;
34
37
  /** Callback when a connection is lost — cleared from slot map. */
35
38
  onConnectionLost;
36
39
  /**
@@ -60,66 +63,88 @@ export class ConnectionManager {
60
63
  this.shouldReuse = opts.shouldReuse ?? (({ client }) => client.ready && client.healthy);
61
64
  this.logTag = opts.logTag ?? 'jsonrpc-connection-manager';
62
65
  this.stoppers = opts.stoppers ?? agentStopperRegistry;
66
+ this.onConnected = opts.onConnected;
63
67
  }
64
68
  /**
65
69
  * Acquire a connection for the given cwd.
66
70
  * Creates a new connection if one does not exist or the previous one was lost
67
71
  * (or, for session-bound protocols, bound to a different session).
68
72
  * Serializes creation so concurrent calls share the same connection.
73
+ *
74
+ * 替换旧连接时必须先等 dispose 完成。旧实现
75
+ * `disposeSlot(cwd)` 不 await 就创建新连接,新旧两个 agent server 会重叠;
76
+ * codex 会报 "thread already has an active writer"(同一 workspace 写锁)。
77
+ * 现在的顺序:等旧连接真正 dispose → 重新读 slot(并发 acquire 可能已建好
78
+ * 新连接,直接复用/等它)→ 才进入创建。创建期间 slot 被 release/disposeAll
79
+ * 删掉时,新连接立即 dispose 并重试,不返回无人托管的连接。
69
80
  */
70
81
  async acquire(cwd, req = {}) {
71
- const existing = this.slots.get(cwd);
72
- if (existing) {
73
- // If a create is in flight, wait for it
74
- if (existing.createPromise) {
75
- return existing.createPromise;
76
- }
77
- // Reuse when the (session-aware) reuse predicate holds — e.g. the client
78
- // is ready and its transport is alive (default), or it is bound to the
79
- // requested session (pi). Otherwise replace it so the next run respawns
80
- // the process.
81
- if (this.shouldReuse({
82
- client: existing.client,
83
- req,
84
- boundSessionId: existing.boundSessionId,
85
- })) {
86
- return existing.client;
87
- }
88
- this.disposeSlot(cwd);
89
- }
90
- const createPromise = this.createClient(cwd, req);
91
- this.slots.set(cwd, {
92
- client: null, // placeholder
93
- boundSessionId: req.sessionId,
94
- idleTimer: null,
95
- createPromise,
96
- });
97
- try {
98
- const client = await createPromise;
99
- // 竞态:创建期间 release()/disposeAll() 已把 slot 删掉(并可能已
100
- // dispose 连接)。此时不能再把新连接塞回 slot——否则会复活一个无人管理、
101
- // 无 idle timer 的连接泄漏。归还给调用者继续使用,但不再缓存。
102
- const slotAfterCreate = this.slots.get(cwd);
103
- if (!slotAfterCreate || slotAfterCreate.createPromise !== createPromise) {
104
- return client;
82
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
83
+ const existing = this.slots.get(cwd);
84
+ if (existing) {
85
+ // If a create is in flight, wait for it
86
+ if (existing.createPromise) {
87
+ return existing.createPromise;
88
+ }
89
+ // Reuse when the (session-aware) reuse predicate holds — e.g. the client
90
+ // is ready and its transport is alive (default), or it is bound to the
91
+ // requested session (pi). Otherwise replace it so the next run respawns
92
+ // the process.
93
+ if (this.shouldReuse({
94
+ client: existing.client,
95
+ req,
96
+ boundSessionId: existing.boundSessionId,
97
+ })) {
98
+ return existing.client;
99
+ }
100
+ // Must finish before a replacement connection is created: two live
101
+ // agent servers for one cwd would contend on the same write lock.
102
+ const disposed = await this.disposeSlot(cwd);
103
+ if (!disposed) {
104
+ throw new Error(`cannot replace connection for cwd=${cwd}: old connection failed to dispose`);
105
+ }
106
+ // Re-read: a concurrent acquire may have installed a new slot (or a
107
+ // create promise) while we awaited the dispose.
108
+ continue;
105
109
  }
106
- this.slots.set(cwd, {
107
- client,
110
+ const createPromise = this.createClient(cwd, req);
111
+ const slot = {
112
+ client: null, // placeholder until connect resolves
108
113
  boundSessionId: req.sessionId,
109
114
  idleTimer: null,
110
- createPromise: null,
111
- });
112
- return client;
113
- }
114
- catch (err) {
115
- // 只删除自己的槽位:并发 release + 重新 acquire 后 slot 可能已被新的
116
- // createPromise 占据,误删会破坏新连接。
117
- const current = this.slots.get(cwd);
118
- if (current && current.createPromise === createPromise) {
119
- this.slots.delete(cwd);
115
+ createPromise,
116
+ };
117
+ this.slots.set(cwd, slot);
118
+ try {
119
+ const client = await createPromise;
120
+ // 竞态:创建期间 release()/disposeAll() 已把 slot 删掉(并可能已
121
+ // dispose 连接)。旧实现把新连接直接交给调用方"继续使用但不缓存",
122
+ // 留下一个无人管理、无 idle timer 的连接。现在主动 dispose 后重试;
123
+ // 连续被释放说明 manager 正在收摊,按失败上报而不是无限重连。
124
+ if (this.slots.get(cwd) !== slot) {
125
+ try {
126
+ await client.dispose();
127
+ }
128
+ catch (err) {
129
+ getLogger().warn(`[${this.logTag}] dispose of released connection failed cwd=${cwd}: ${err}`);
130
+ }
131
+ continue;
132
+ }
133
+ slot.client = client;
134
+ slot.createPromise = null;
135
+ return client;
136
+ }
137
+ catch (err) {
138
+ // 只删除自己的槽位:并发 release + 重新 acquire 后 slot 可能已被新的
139
+ // createPromise 占据,误删会破坏新连接。
140
+ const current = this.slots.get(cwd);
141
+ if (current && current.createPromise === createPromise) {
142
+ this.slots.delete(cwd);
143
+ }
144
+ throw err;
120
145
  }
121
- throw err;
122
146
  }
147
+ throw new Error(`cannot acquire connection for cwd=${cwd}: slot repeatedly released`);
123
148
  }
124
149
  /**
125
150
  * Record the session id a fresh connection actually bound to (e.g. discovered
@@ -135,7 +160,7 @@ export class ConnectionManager {
135
160
  * Release (dispose) a connection for the given cwd.
136
161
  */
137
162
  async release(cwd) {
138
- this.disposeSlot(cwd);
163
+ await this.disposeSlot(cwd);
139
164
  }
140
165
  /**
141
166
  * Dispose all connections.
@@ -203,7 +228,8 @@ export class ConnectionManager {
203
228
  }
204
229
  },
205
230
  });
206
- await client.connect();
231
+ const initResult = await client.connect();
232
+ this.onConnected?.(initResult, cwd);
207
233
  return client;
208
234
  }
209
235
  clearIdleTimer(slot) {
@@ -212,10 +238,15 @@ export class ConnectionManager {
212
238
  slot.idleTimer = null;
213
239
  }
214
240
  }
241
+ /**
242
+ * Dispose the slot for `cwd`. Returns false when the underlying client's
243
+ * dispose failed — callers replacing a connection must abort the replacement
244
+ * rather than let two agent servers overlap.
245
+ */
215
246
  async disposeSlot(cwd) {
216
247
  const slot = this.slots.get(cwd);
217
248
  if (!slot)
218
- return;
249
+ return true;
219
250
  this.slots.delete(cwd);
220
251
  this.clearIdleTimer(slot);
221
252
  if (slot.client) {
@@ -224,8 +255,10 @@ export class ConnectionManager {
224
255
  }
225
256
  catch (err) {
226
257
  getLogger().warn(`[${this.logTag}] dispose error cwd=${cwd}: ${err}`);
258
+ return false;
227
259
  }
228
260
  }
261
+ return true;
229
262
  }
230
263
  }
231
264
  //# sourceMappingURL=connection-manager.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Correlate DSH's durable approval audit with its live answer waterfall.
3
+ *
4
+ * `session/follow` carries `approval/asked` / `approval/decided`; `$events`
5
+ * carries the answerable `approval/request` waterfall. The audit id is the
6
+ * stable identity shown on the Feishu card; the waterfall contributes the
7
+ * ephemeral `eventId` + `clientId` needed to answer it. Both are correlated by
8
+ * `callId`, with an order-independent pairing so either transport may arrive
9
+ * first.
10
+ */
11
+ export interface DshApprovalAudit {
12
+ approvalId: string;
13
+ callId?: string;
14
+ toolName: string;
15
+ reason?: string;
16
+ }
17
+ export interface DshApprovalWaterfall {
18
+ eventId: string;
19
+ clientId: string;
20
+ callId?: string;
21
+ toolName: string;
22
+ reason?: string;
23
+ }
24
+ export interface DshApprovalBinding {
25
+ audit: DshApprovalAudit;
26
+ waterfall: DshApprovalWaterfall;
27
+ }
28
+ export declare class DshApprovalTracker {
29
+ private readonly audits;
30
+ private readonly waterfallByCallId;
31
+ private readonly bindings;
32
+ private readonly approvalIdByEventId;
33
+ private readonly waiters;
34
+ /** Record `approval/asked`; pair immediately when its waterfall is known. */
35
+ onAuditAsked(audit: DshApprovalAudit): DshApprovalBinding | undefined;
36
+ /** Record an `approval/request` waterfall; pair when its audit is known. */
37
+ onWaterfall(waterfall: DshApprovalWaterfall): DshApprovalBinding | undefined;
38
+ /** Mark an audit resolved (local response or an external decision). */
39
+ onDecided(approvalId: string): DshApprovalBinding | undefined;
40
+ /** Mark a waterfall cancelled by the Host (another client answered it). */
41
+ onCancelled(eventId: string): DshApprovalBinding | undefined;
42
+ getBinding(approvalId: string): DshApprovalBinding | undefined;
43
+ /**
44
+ * Wait for a waterfall to arrive after the audit card was rendered. The
45
+ * audit is appended before the waterfall is dispatched, so this window is
46
+ * normally tiny; the bound keeps a fast button click from failing.
47
+ */
48
+ waitForBinding(approvalId: string, timeoutMs: number, signal?: AbortSignal): Promise<DshApprovalBinding | undefined>;
49
+ /** Drop all in-flight state when a run ends. */
50
+ clear(): void;
51
+ private bind;
52
+ private resolveWaiters;
53
+ }