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.
- package/CHANGELOG.md +38 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/bridge/approval-coordinator.d.ts +18 -1
- package/dist/bridge/approval-coordinator.js +57 -10
- package/dist/bridge/index.js +90 -17
- package/dist/card/approval-render.js +18 -0
- package/dist/card/run-state.js +10 -5
- package/dist/config/index.d.ts +16 -0
- package/dist/config/index.js +46 -6
- package/dist/config/opencode-config.d.ts +3 -3
- package/dist/config/opencode-config.js +38 -22
- package/dist/inbound/authorize.d.ts +34 -0
- package/dist/inbound/authorize.js +31 -0
- package/dist/index.js +116 -63
- package/dist/instance-lock.d.ts +9 -0
- package/dist/instance-lock.js +113 -42
- package/dist/router/config/dsh.d.ts +51 -21
- package/dist/router/config/dsh.js +255 -66
- package/dist/router/config/opencode.js +16 -6
- package/dist/router/config/types.d.ts +6 -3
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.js +13 -5
- package/dist/runner/codex/app-server/translator.js +21 -7
- package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
- package/dist/runner/common/acp/base-acp-runner.js +83 -8
- package/dist/runner/common/acp/protocol-types.d.ts +10 -2
- package/dist/runner/common/acp/protocol-types.js +3 -1
- package/dist/runner/common/connection-based-runner.d.ts +15 -0
- package/dist/runner/common/connection-based-runner.js +40 -5
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
- package/dist/runner/dsh/approval.d.ts +53 -0
- package/dist/runner/dsh/approval.js +161 -0
- package/dist/runner/dsh/auth.d.ts +36 -0
- package/dist/runner/dsh/auth.js +118 -0
- package/dist/runner/dsh/client.d.ts +161 -42
- package/dist/runner/dsh/client.js +551 -244
- package/dist/runner/dsh/daemon.d.ts +85 -0
- package/dist/runner/dsh/daemon.js +272 -0
- package/dist/runner/dsh/merge.d.ts +9 -0
- package/dist/runner/dsh/merge.js +51 -0
- package/dist/runner/dsh/runner.d.ts +94 -11
- package/dist/runner/dsh/runner.js +553 -67
- package/dist/runner/dsh/translator.d.ts +43 -3
- package/dist/runner/dsh/translator.js +115 -38
- package/dist/runner/dsh/types.d.ts +189 -27
- package/dist/runner/dsh/types.js +11 -5
- package/dist/runner/kimi/acp/runner.js +15 -3
- package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
- package/dist/runner/opencode/acp/error-monitor.js +2 -2
- package/dist/runner/opencode/acp/runner.d.ts +9 -0
- package/dist/runner/opencode/acp/runner.js +40 -1
- package/dist/runner/opencode/acp/translator.d.ts +4 -2
- package/dist/runner/opencode/acp/translator.js +12 -3
- package/dist/runner/pi/rpc/translator.d.ts +2 -0
- package/dist/runner/pi/rpc/translator.js +27 -1
- package/dist/runner/types.d.ts +34 -0
- package/dist/runner/types.js +24 -0
- package/dist/session/codex/rollout-reader.js +44 -7
- package/dist/session/dsh/sessions.d.ts +45 -6
- package/dist/session/dsh/sessions.js +171 -27
- package/dist/session/opencode/sessions.d.ts +18 -7
- package/dist/session/opencode/sessions.js +294 -15
- package/dist/session/pi/sessions.js +6 -2
- 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) =>
|
|
141
|
-
|
|
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) =>
|
|
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) =>
|
|
198
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
107
|
-
|
|
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
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
this.slots.
|
|
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
|
+
}
|