@qwen-code/channel-base 0.23.1-preview.1 → 0.23.1

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.
@@ -7,8 +7,8 @@ import { DmGate } from './DmGate.js';
7
7
  import { GroupHistoryStore } from './group-history-store.js';
8
8
  import { SenderGate } from './SenderGate.js';
9
9
  import { PairingStore } from './PairingStore.js';
10
- import { SessionRouter } from './SessionRouter.js';
11
- import { NamedSessionManager, } from './named-session-manager.js';
10
+ import { SessionRouter, readDaemonHttpErrorCode } from './SessionRouter.js';
11
+ import { NamedSessionManager, NamedSessionTaskError, } from './named-session-manager.js';
12
12
  import { getGlobalQwenDir } from './paths.js';
13
13
  import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, sanitizeDisplayText, truncateCodePoints, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js';
14
14
  import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
@@ -160,14 +160,17 @@ export class ChannelBase {
160
160
  bridgeToolCallListener = (event) => {
161
161
  this.dispatchToolCall(event);
162
162
  };
163
- bridgeBackgroundResponseListener = (sessionId, text) => {
164
- void this.dispatchBackgroundResponse(sessionId, text).catch((err) => {
163
+ bridgeBackgroundResponseListener = (sessionId, text, context) => {
164
+ void this.dispatchBackgroundResponse(sessionId, text, context).catch((err) => {
165
165
  process.stderr.write(`[${this.name}] background response delivery failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(err)}\n`);
166
166
  });
167
167
  };
168
168
  bridgeSessionDiedListener = (event) => {
169
169
  this.onSessionDied(event.sessionId);
170
170
  };
171
+ bridgeDisconnectedListener = () => {
172
+ this.onBridgeDisconnected();
173
+ };
171
174
  bridgePermissionRequestListener = (event) => {
172
175
  void this.dispatchPermissionRequest(event).catch((err) => {
173
176
  process.stderr.write(`[${this.name}] permission relay failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(err)}\n`);
@@ -209,13 +212,19 @@ export class ChannelBase {
209
212
  }
210
213
  this.onToolCall(chatId, event);
211
214
  }
212
- async dispatchBackgroundResponse(sessionId, text) {
213
- let target = this.router.getTarget(sessionId);
214
- if (!target ||
215
- target.channelName !== this.name ||
216
- text.trim().length === 0) {
215
+ async dispatchBackgroundResponse(sessionId, text, _context) {
216
+ if (text.trim().length === 0)
217
+ return;
218
+ const delivery = await this.resolveBackgroundResponseDelivery(sessionId);
219
+ if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
217
220
  return;
218
221
  }
222
+ await this.deliverBackgroundResponseToTarget(sessionId, text, delivery);
223
+ }
224
+ async resolveBackgroundResponseDelivery(sessionId) {
225
+ let target = this.router.getTarget(sessionId);
226
+ if (!target || target.channelName !== this.name)
227
+ return undefined;
219
228
  let sourceLabel;
220
229
  if (this.namedSessions) {
221
230
  const presentation = await this.namedSessions.resolvePresentation(sessionId);
@@ -235,6 +244,10 @@ export class ChannelBase {
235
244
  target = currentTarget;
236
245
  sourceLabel = this.createSourceLabel(presentation, target);
237
246
  }
247
+ return { target, sourceLabel };
248
+ }
249
+ async deliverBackgroundResponseToTarget(sessionId, text, delivery) {
250
+ const { target, sourceLabel } = delivery;
238
251
  if (this.supportsProactiveSend() && this.supportsProactiveTarget(target)) {
239
252
  if (sourceLabel) {
240
253
  await this.pushProactive(target, text, sourceLabel);
@@ -1658,6 +1671,7 @@ export class ChannelBase {
1658
1671
  ]);
1659
1672
  if (!cancelled) {
1660
1673
  this.cancelBtw(sessionId);
1674
+ this.onSessionRetiring(sessionId);
1661
1675
  this.router.removeSessionId(sessionId);
1662
1676
  this.instructedSessions.delete(sessionId);
1663
1677
  this.unattendedMemorySessions.delete(sessionId);
@@ -1796,10 +1810,19 @@ export class ChannelBase {
1796
1810
  this.unattendedMemorySessions.delete(sessionId);
1797
1811
  this.removePendingPermissionsForSession(sessionId);
1798
1812
  }
1813
+ /**
1814
+ * Called when the standalone ACP bridge process exits. Its in-flight turns
1815
+ * never settle, but crash recovery restores the sessions on a fresh bridge,
1816
+ * so overrides must clear only turn-scoped transient state (never session
1817
+ * routing) and must not fabricate terminal outcomes for interrupted turns.
1818
+ */
1819
+ onBridgeDisconnected() { }
1820
+ onSessionRetiring(_sessionId) { }
1799
1821
  attachBridgeEvents(bridge) {
1800
1822
  bridge.on('toolCall', this.bridgeToolCallListener);
1801
1823
  bridge.on('backgroundResponse', this.bridgeBackgroundResponseListener);
1802
1824
  bridge.on('sessionDied', this.bridgeSessionDiedListener);
1825
+ bridge.on('disconnected', this.bridgeDisconnectedListener);
1803
1826
  bridge.on('permissionRequest', this.bridgePermissionRequestListener);
1804
1827
  bridge.on('permissionResolved', this.bridgePermissionResolvedListener);
1805
1828
  }
@@ -1807,6 +1830,7 @@ export class ChannelBase {
1807
1830
  bridge.off('toolCall', this.bridgeToolCallListener);
1808
1831
  bridge.off('backgroundResponse', this.bridgeBackgroundResponseListener);
1809
1832
  bridge.off('sessionDied', this.bridgeSessionDiedListener);
1833
+ bridge.off('disconnected', this.bridgeDisconnectedListener);
1810
1834
  bridge.off('permissionRequest', this.bridgePermissionRequestListener);
1811
1835
  bridge.off('permissionResolved', this.bridgePermissionResolvedListener);
1812
1836
  }
@@ -2307,6 +2331,21 @@ export class ChannelBase {
2307
2331
  binding.released = true;
2308
2332
  this.releaseQueuedTurn(binding.sessionId);
2309
2333
  }
2334
+ /**
2335
+ * Follow a task onto the session its reload healed to (a superseded
2336
+ * redirect). The queue reservation and the staleness generation are keyed
2337
+ * by session id, so both must move with the binding: leaving them behind
2338
+ * keeps the stale id "busy" forever and drops the turn on a generation the
2339
+ * replacement never had.
2340
+ */
2341
+ moveNamedTurnBinding(binding, sessionId) {
2342
+ if (binding.sessionId !== null) {
2343
+ this.releaseQueuedTurn(binding.sessionId);
2344
+ }
2345
+ binding.sessionId = sessionId;
2346
+ binding.generation = this.sessionGenerations.get(sessionId) ?? 0;
2347
+ this.reserveQueuedTurn(sessionId);
2348
+ }
2310
2349
  finishNamedTurnBinding(envelope) {
2311
2350
  const binding = this.namedTurnBindings.get(envelope);
2312
2351
  if (!binding)
@@ -2367,13 +2406,67 @@ export class ChannelBase {
2367
2406
  return true;
2368
2407
  }
2369
2408
  }
2409
+ /**
2410
+ * The actionable recovery message for a worktree task whose ownership state
2411
+ * the daemon refused to restore. Both codes come from the load/resume route
2412
+ * — the reset route resumes an interrupted transfer itself and reports a
2413
+ * broken marker as invalid state — so they surface on selection and on a
2414
+ * message, wrapped in the manager's generic load failure.
2415
+ *
2416
+ * Clearing always acts on the *selected* task, so pointing the user at a
2417
+ * clear is only safe when the task that failed is the selected one. Aimed at
2418
+ * any other task it would run a full ownership transfer against a healthy
2419
+ * one and destroy that conversation, so the alternative points at the close
2420
+ * that works on the broken task without loading it. Both name that task.
2421
+ */
2422
+ async worktreeRecoveryMessage(envelope, error) {
2423
+ const code = readDaemonHttpErrorCode(error);
2424
+ if (code !== 'worktree_reset_interrupted' &&
2425
+ code !== 'worktree_marker_missing') {
2426
+ return undefined;
2427
+ }
2428
+ const interrupted = code === 'worktree_reset_interrupted';
2429
+ const failedTaskName = error instanceof NamedSessionTaskError ? error.taskName : undefined;
2430
+ // This message bypasses the wrapper's sanitizeDisplayText, so bound the one
2431
+ // piece of interpolated text it adds.
2432
+ const safeTaskName = failedTaskName === undefined
2433
+ ? undefined
2434
+ : sanitizeDisplayText(failedTaskName, 32);
2435
+ const subject = safeTaskName === undefined ? 'The task' : `Task "${safeTaskName}"`;
2436
+ const problem = interrupted
2437
+ ? 'was interrupted while being reset'
2438
+ : 'cannot verify its worktree because its ownership marker is missing';
2439
+ if (safeTaskName !== undefined &&
2440
+ failedTaskName === (await this.selectedTaskName(envelope))) {
2441
+ return `${subject} ${problem}. Its files were not changed. ${interrupted
2442
+ ? 'Clear the task again to finish the reset.'
2443
+ : 'Clear the task to restart it in the same worktree, or close it.'}`;
2444
+ }
2445
+ const remedy = safeTaskName === undefined
2446
+ ? 'select this task first or close it'
2447
+ : `select this task first or close it with ${this.prefixedCommand(`/session close ${safeTaskName}`)}`;
2448
+ return `${subject} ${problem}. Its files were not changed. Clearing now would reset the selected task instead, so ${remedy}.`;
2449
+ }
2450
+ async selectedTaskName(envelope) {
2451
+ const namedSessions = this.namedSessions;
2452
+ if (!namedSessions)
2453
+ return undefined;
2454
+ try {
2455
+ return (await namedSessions.current(this.namedSessionOwner(envelope)))
2456
+ ?.name;
2457
+ }
2458
+ catch {
2459
+ return undefined;
2460
+ }
2461
+ }
2370
2462
  async sendNamedSessionError(envelope, error) {
2371
2463
  if (error instanceof Error && error.cause !== undefined) {
2372
2464
  process.stderr.write(`[${sanitizeLogText(this.name, 64)}] named-session operation failed: ${this.lifecycleError(error)} | cause: ${this.lifecycleError(error.cause)}\n`);
2373
2465
  }
2374
- const message = error instanceof Error
2375
- ? sanitizeDisplayText(error.message, 500)
2376
- : 'Named-session operation failed.';
2466
+ const message = (await this.worktreeRecoveryMessage(envelope, error)) ??
2467
+ (error instanceof Error
2468
+ ? sanitizeDisplayText(error.message, 500)
2469
+ : 'Named-session operation failed.');
2377
2470
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, message || 'Named-session operation failed.');
2378
2471
  }
2379
2472
  async handleNamedSessionsCommand(envelope, args) {
@@ -2424,7 +2517,8 @@ export class ChannelBase {
2424
2517
  ? 'worktree'
2425
2518
  : 'shared';
2426
2519
  if ((isolation === 'shared' && parts.length !== 1) ||
2427
- (isolation === 'worktree' && parts.length !== 2)) {
2520
+ (isolation === 'worktree' && parts.length !== 2) ||
2521
+ parts[0]?.startsWith('-')) {
2428
2522
  break;
2429
2523
  }
2430
2524
  const created = await namedSessions.create(owner, parts[0], isolation);
@@ -2443,8 +2537,10 @@ export class ChannelBase {
2443
2537
  break;
2444
2538
  const closing = await namedSessions.lookup(owner, parts[0]);
2445
2539
  const result = await namedSessions.close(owner, parts[0]);
2446
- if (closing)
2540
+ if (closing) {
2447
2541
  this.cancelBtw(closing.sessionId);
2542
+ this.onSessionRetiring(closing.sessionId);
2543
+ }
2448
2544
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.active
2449
2545
  ? `Closed task "${result.closed.name}". Selected "${result.active.name}".`
2450
2546
  : `Closed task "${result.closed.name}". No task is selected.`);
@@ -2492,14 +2588,31 @@ export class ChannelBase {
2492
2588
  registerSharedCommands() {
2493
2589
  const doClear = async (envelope) => {
2494
2590
  let resetTaskName;
2591
+ let resetWorktreeKept = false;
2495
2592
  let removedIds;
2593
+ const retiringSessionId = this.namedSessions
2594
+ ? undefined
2595
+ : this.router.getSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
2596
+ if (retiringSessionId)
2597
+ this.onSessionRetiring(retiringSessionId);
2496
2598
  if (this.namedSessions) {
2497
2599
  try {
2498
2600
  const reset = await this.namedSessions.reset(this.namedSessionOwner(envelope));
2499
2601
  resetTaskName = reset?.name;
2602
+ resetWorktreeKept = reset?.worktreeKept ?? false;
2500
2603
  removedIds = reset ? [reset.previousSessionId] : [];
2501
2604
  }
2502
2605
  catch (error) {
2606
+ // The reset route's busy signal has its own wording; every other
2607
+ // typed failure goes through the shared named-session surface.
2608
+ const code = readDaemonHttpErrorCode(error);
2609
+ if (code !== undefined) {
2610
+ process.stderr.write(`[${sanitizeLogText(this.name, 64)}] worktree reset failed (${sanitizeLogText(code, 64)}): ${this.lifecycleError(error)}\n`);
2611
+ }
2612
+ if (code === 'worktree_reset_active') {
2613
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.');
2614
+ return;
2615
+ }
2503
2616
  await this.sendNamedSessionError(envelope, error);
2504
2617
  return;
2505
2618
  }
@@ -2510,6 +2623,8 @@ export class ChannelBase {
2510
2623
  this.clearPendingGroupHistory(envelope);
2511
2624
  if (removedIds.length > 0) {
2512
2625
  for (const id of removedIds) {
2626
+ if (id !== retiringSessionId)
2627
+ this.onSessionRetiring(id);
2513
2628
  this.cancelBtw(id);
2514
2629
  // Audit: clearing a SHARED session wipes the conversation for every
2515
2630
  // participant, so record who triggered it (sanitized display name +
@@ -2600,7 +2715,9 @@ export class ChannelBase {
2600
2715
  this.discardRetiredSession(this.bridge, id, 'cleared');
2601
2716
  }
2602
2717
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, resetTaskName
2603
- ? `Task "${resetTaskName}" reset with a fresh conversation.`
2718
+ ? resetWorktreeKept
2719
+ ? `Task "${resetTaskName}" reset with a fresh conversation; its worktree and files were kept.`
2720
+ : `Task "${resetTaskName}" reset with a fresh conversation.`
2604
2721
  : 'Session cleared. The next message starts a fresh conversation.');
2605
2722
  }
2606
2723
  else {
@@ -4370,8 +4487,10 @@ export class ChannelBase {
4370
4487
  return;
4371
4488
  }
4372
4489
  try {
4373
- const resumed = await this.namedSessions.resumeReserved(this.namedSessionOwner(envelope), namedTurn.sessionId);
4374
- sessionId = resumed ? namedTurn.sessionId : undefined;
4490
+ sessionId = await this.namedSessions.resumeReserved(this.namedSessionOwner(envelope), namedTurn.sessionId);
4491
+ if (sessionId && sessionId !== namedTurn.sessionId) {
4492
+ this.moveNamedTurnBinding(namedTurn, sessionId);
4493
+ }
4375
4494
  }
4376
4495
  catch (error) {
4377
4496
  await this.sendNamedSessionError(envelope, error);