@qwen-code/channel-base 0.19.4 → 0.19.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/dist/ChannelAgentBridge.d.ts +6 -0
- package/dist/ChannelBase.d.ts +26 -1
- package/dist/ChannelBase.js +409 -50
- package/dist/ChannelBase.js.map +1 -1
- package/dist/ChannelLoopScheduler.d.ts +4 -0
- package/dist/ChannelLoopScheduler.js +5 -0
- package/dist/ChannelLoopScheduler.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +6 -1
- package/dist/DaemonChannelBridge.js +46 -7
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.js +1 -1
- package/dist/sanitize.js.map +1 -1
- package/dist/types.d.ts +65 -0
- package/dist/types.js +4 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -22,6 +22,8 @@ const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
|
|
|
22
22
|
const GROUP_HISTORY_ENTRY_TEXT_LIMIT = 1000;
|
|
23
23
|
const GROUP_HISTORY_ENTRY_METADATA_LIMIT = 256;
|
|
24
24
|
const LOOP_CANCEL_GRACE_MS = 5000;
|
|
25
|
+
/** Sentinel message for the loop-prompt timeout rejection; matched by identity below. */
|
|
26
|
+
const LOOP_TIMED_OUT_MESSAGE = 'loop timed out';
|
|
25
27
|
/**
|
|
26
28
|
* Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
|
|
27
29
|
* plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
|
|
@@ -51,6 +53,9 @@ export class ChannelBase {
|
|
|
51
53
|
gate;
|
|
52
54
|
router;
|
|
53
55
|
name;
|
|
56
|
+
/** Resolved (defaulted + frozen) identity/scope — adapters should read these, not raw config. */
|
|
57
|
+
identity;
|
|
58
|
+
memoryScope;
|
|
54
59
|
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
|
|
55
60
|
proxy;
|
|
56
61
|
channelMemory;
|
|
@@ -72,19 +77,43 @@ export class ChannelBase {
|
|
|
72
77
|
/** Per-session message buffer for collect mode. */
|
|
73
78
|
collectBuffers = new Map();
|
|
74
79
|
bridgeToolCallListener = (event) => {
|
|
75
|
-
|
|
76
|
-
if (target) {
|
|
77
|
-
this.onToolCall(target.chatId, event);
|
|
78
|
-
}
|
|
80
|
+
this.dispatchToolCall(event);
|
|
79
81
|
};
|
|
80
82
|
bridgeSessionDiedListener = (event) => {
|
|
81
83
|
this.onSessionDied(event.sessionId);
|
|
82
84
|
};
|
|
85
|
+
dispatchToolCall(event) {
|
|
86
|
+
const target = this.router.getTarget(event.sessionId);
|
|
87
|
+
const active = this.activePrompts.get(event.sessionId);
|
|
88
|
+
const chatId = active?.chatId ?? target?.chatId;
|
|
89
|
+
if (!chatId) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (active && !active.cancelled && !active.cancelPending) {
|
|
93
|
+
// `?? ''`: dispatchToolCall is a public entry point — a third-party bridge
|
|
94
|
+
// omitting a field must not throw out of its emit('toolCall').
|
|
95
|
+
const safeToolCall = {
|
|
96
|
+
sessionId: event.sessionId,
|
|
97
|
+
toolCallId: event.toolCallId,
|
|
98
|
+
kind: sanitizeLogText(event.kind ?? '', 20),
|
|
99
|
+
title: sanitizeLogText(event.title ?? '', 80),
|
|
100
|
+
status: sanitizeLogText(event.status ?? '', 20),
|
|
101
|
+
};
|
|
102
|
+
this.emitTaskLifecycle({
|
|
103
|
+
...this.lifecycleBase(chatId, event.sessionId, active.messageId),
|
|
104
|
+
type: 'tool_call',
|
|
105
|
+
toolCall: safeToolCall,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
this.onToolCall(chatId, event);
|
|
109
|
+
}
|
|
83
110
|
constructor(name, config, bridge, options) {
|
|
84
111
|
this.name = name;
|
|
85
112
|
this.config = config;
|
|
86
113
|
this.bridge = bridge;
|
|
87
114
|
this.proxy = options?.proxy;
|
|
115
|
+
this.identity = Object.freeze(this.resolveIdentity(name, config));
|
|
116
|
+
this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
|
|
88
117
|
this.channelMemory = options?.channelMemory;
|
|
89
118
|
this.groupHistory = new GroupHistoryStore(options?.groupHistoryPath ??
|
|
90
119
|
join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
|
|
@@ -104,6 +133,101 @@ export class ChannelBase {
|
|
|
104
133
|
this.attachBridgeEvents(bridge);
|
|
105
134
|
}
|
|
106
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Adapter hook for task lifecycle events — the canonical way to track task
|
|
138
|
+
* state (onPromptStart/onPromptEnd are retained for back-compat). The prompt
|
|
139
|
+
* flow never awaits this hook; an async override's rejection is caught and
|
|
140
|
+
* logged, nothing more.
|
|
141
|
+
*/
|
|
142
|
+
onTaskLifecycle(_event) { }
|
|
143
|
+
emitTaskLifecycle(event) {
|
|
144
|
+
try {
|
|
145
|
+
const result = this.onTaskLifecycle(event);
|
|
146
|
+
if (result && typeof result.catch === 'function') {
|
|
147
|
+
result.catch((err) => {
|
|
148
|
+
this.logTaskLifecycleError(event, err);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
this.logTaskLifecycleError(event, err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
logTaskLifecycleError(event, err) {
|
|
157
|
+
const channel = sanitizeLogText(this.name, 64);
|
|
158
|
+
const sessionId = sanitizeLogText(event.sessionId, 64);
|
|
159
|
+
const stack = err instanceof Error && err.stack
|
|
160
|
+
? ` | ${sanitizeLogText(err.stack, 500)}`
|
|
161
|
+
: '';
|
|
162
|
+
process.stderr.write(`[${channel}] onTaskLifecycle threw for ${event.type} session ${sessionId}: ${this.lifecycleError(err)}${stack}\n`);
|
|
163
|
+
}
|
|
164
|
+
lifecycleError(err) {
|
|
165
|
+
return sanitizeLogText(err instanceof Error ? err.message : String(err), 200);
|
|
166
|
+
}
|
|
167
|
+
emitTaskCancellation(active, sessionId, reason) {
|
|
168
|
+
if (active.cancellationEmitted) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
active.cancellationEmitted = true;
|
|
172
|
+
this.emitTaskLifecycle({
|
|
173
|
+
...this.lifecycleBase(active.chatId, sessionId, active.messageId),
|
|
174
|
+
type: 'cancelled',
|
|
175
|
+
reason,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
resolveIdentity(name, config) {
|
|
179
|
+
return {
|
|
180
|
+
id: config.identity?.id || `channel:${name}`,
|
|
181
|
+
displayName: config.identity?.displayName || name,
|
|
182
|
+
...(config.identity?.description
|
|
183
|
+
? { description: config.identity.description }
|
|
184
|
+
: {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
resolveMemoryScope(name, config) {
|
|
188
|
+
return {
|
|
189
|
+
namespace: config.memoryScope?.namespace || `channel:${name}`,
|
|
190
|
+
mode: config.memoryScope?.mode ?? 'metadata-only',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Built once — identity/memoryScope are frozen at construction. */
|
|
194
|
+
boundaryPrompt;
|
|
195
|
+
channelBoundaryPrompt() {
|
|
196
|
+
if (this.boundaryPrompt !== undefined) {
|
|
197
|
+
return this.boundaryPrompt;
|
|
198
|
+
}
|
|
199
|
+
const identityLines = [
|
|
200
|
+
'Channel identity:',
|
|
201
|
+
`- id: ${sanitizeQuotedText(this.identity.id, 128)}`,
|
|
202
|
+
`- display name: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
|
|
203
|
+
...(this.identity.description
|
|
204
|
+
? [
|
|
205
|
+
`- description: ${sanitizeQuotedText(this.identity.description, 256)}`,
|
|
206
|
+
]
|
|
207
|
+
: []),
|
|
208
|
+
];
|
|
209
|
+
const memoryLines = [
|
|
210
|
+
'Memory scope:',
|
|
211
|
+
`- namespace: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
|
|
212
|
+
`- mode: ${this.memoryScope.mode}`,
|
|
213
|
+
'- data from other channels must not be shared.',
|
|
214
|
+
];
|
|
215
|
+
this.boundaryPrompt = [...identityLines, '', ...memoryLines].join('\n');
|
|
216
|
+
return this.boundaryPrompt;
|
|
217
|
+
}
|
|
218
|
+
shouldPrependChannelBoundaryPrompt() {
|
|
219
|
+
return Boolean(this.config.identity || this.config.memoryScope);
|
|
220
|
+
}
|
|
221
|
+
lifecycleBase(chatId, sessionId, messageId) {
|
|
222
|
+
return {
|
|
223
|
+
channelName: this.name,
|
|
224
|
+
chatId,
|
|
225
|
+
sessionId,
|
|
226
|
+
...(messageId ? { messageId } : {}),
|
|
227
|
+
identity: this.identity,
|
|
228
|
+
memoryScope: this.memoryScope,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
107
231
|
supportsProactiveSend() {
|
|
108
232
|
return false;
|
|
109
233
|
}
|
|
@@ -148,7 +272,9 @@ export class ChannelBase {
|
|
|
148
272
|
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd);
|
|
149
273
|
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
150
274
|
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
151
|
-
|
|
275
|
+
// Without the delivery-contract sentence the model treats "post X" prompts
|
|
276
|
+
// as an action it must perform itself and goes hunting for send credentials.
|
|
277
|
+
let promptText = `[Loop "${label}" created by ${createdBy}] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\n${sanitizePromptText(job.prompt)}`;
|
|
152
278
|
const shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
153
279
|
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
154
280
|
const generation = this.sessionGenerations.get(sessionId) ?? 0;
|
|
@@ -187,6 +313,11 @@ export class ChannelBase {
|
|
|
187
313
|
if (this.config.instructions) {
|
|
188
314
|
context.push(this.config.instructions);
|
|
189
315
|
}
|
|
316
|
+
// Boundary block goes last: recency bias means later instructions win,
|
|
317
|
+
// and the isolation boundary must not be overridable by operator text.
|
|
318
|
+
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
319
|
+
context.push(this.channelBoundaryPrompt());
|
|
320
|
+
}
|
|
190
321
|
if (context.length > 0) {
|
|
191
322
|
promptText = `${context.join('\n\n')}\n\n${promptText}`;
|
|
192
323
|
}
|
|
@@ -211,35 +342,116 @@ export class ChannelBase {
|
|
|
211
342
|
resolve: doneResolve,
|
|
212
343
|
chatId: job.target.chatId,
|
|
213
344
|
messageId: job.id,
|
|
345
|
+
loopPrompt: true,
|
|
214
346
|
};
|
|
215
347
|
this.activePrompts.set(sessionId, promptState);
|
|
216
|
-
this.
|
|
348
|
+
this.emitTaskLifecycle({
|
|
349
|
+
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
350
|
+
type: 'started',
|
|
351
|
+
});
|
|
352
|
+
// Guarded: an adapter indicator failure must not orphan the started
|
|
353
|
+
// event (no terminal) or leak the activePrompts entry.
|
|
354
|
+
// No messageId: the hook contract passes INBOUND platform message ids,
|
|
355
|
+
// and adapters act on them (cards, reactions) — a loop job id would
|
|
356
|
+
// collide. Lifecycle events still carry job.id for correlation.
|
|
357
|
+
try {
|
|
358
|
+
this.onPromptStart(job.target.chatId, sessionId);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
process.stderr.write(`[${this.name}] onPromptStart threw in loop ${job.id} for session ${sessionId}: ${this.lifecycleError(err)}\n`);
|
|
362
|
+
}
|
|
363
|
+
// Same hold-and-replay contract as handleInbound's onChunk: visible
|
|
364
|
+
// sinks stay out of the transcript while a cancel is pending.
|
|
365
|
+
const heldChunks = [];
|
|
366
|
+
const releaseHeldChunks = () => {
|
|
367
|
+
for (const held of heldChunks.splice(0)) {
|
|
368
|
+
this.emitTaskLifecycle({
|
|
369
|
+
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
370
|
+
type: 'text_chunk',
|
|
371
|
+
chunk: held,
|
|
372
|
+
});
|
|
373
|
+
this.onResponseChunk(job.target.chatId, held, sessionId);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
217
376
|
const onChunk = (sid, chunk) => {
|
|
218
|
-
if (sid
|
|
219
|
-
|
|
377
|
+
if (sid !== sessionId || promptState.cancelled) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
heldChunks.push(chunk);
|
|
381
|
+
if (!promptState.cancelPending) {
|
|
382
|
+
releaseHeldChunks();
|
|
220
383
|
}
|
|
221
384
|
};
|
|
222
385
|
const promptBridge = this.bridge;
|
|
223
386
|
promptBridge.on('textChunk', onChunk);
|
|
224
387
|
try {
|
|
225
388
|
const response = await this.runLoopBridgePrompt(promptBridge, sessionId, promptText, promptState, job.id, options.timeoutMs);
|
|
226
|
-
|
|
227
|
-
const cancelled = await promptState.cancelRequested;
|
|
228
|
-
if (cancelled) {
|
|
229
|
-
promptState.cancelled = true;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
389
|
+
await this.settleCancelRequested(promptState);
|
|
232
390
|
if (promptState.cancelled) {
|
|
233
|
-
throw new ChannelLoopSkippedError('loop cancelled before delivery');
|
|
391
|
+
throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
|
|
234
392
|
}
|
|
393
|
+
releaseHeldChunks();
|
|
235
394
|
if (options.shouldContinue && !(await options.shouldContinue())) {
|
|
236
395
|
throw new ChannelLoopSkippedError('loop dropped before delivery');
|
|
237
396
|
}
|
|
397
|
+
if (promptState.cancelled) {
|
|
398
|
+
throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
|
|
399
|
+
}
|
|
238
400
|
if (response) {
|
|
401
|
+
promptState.deliveryStarted = true;
|
|
239
402
|
await this.pushProactive(job.target, response);
|
|
240
403
|
}
|
|
404
|
+
// Once delivery started the run counts as completed — a cancel settling
|
|
405
|
+
// during/after the send must not convert a delivered run into a skip
|
|
406
|
+
// (a one-shot loop would stay enabled and deliver twice).
|
|
407
|
+
if (!promptState.deliveryStarted) {
|
|
408
|
+
await this.settleCancelRequested(promptState);
|
|
409
|
+
if (promptState.cancelled) {
|
|
410
|
+
throw new ChannelLoopSkippedError('loop cancelled before delivery', 'cancel_command');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// /clear can evict mid-delivery and emit its own terminal event; never
|
|
414
|
+
// follow a cancelled event with completed for the same prompt.
|
|
415
|
+
if (!promptState.cancellationEmitted) {
|
|
416
|
+
this.emitTaskLifecycle({
|
|
417
|
+
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
418
|
+
type: 'completed',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
241
421
|
return response;
|
|
242
422
|
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
// Once delivery started, a late-settling cancel must not flip
|
|
425
|
+
// `cancelled` here — it would suppress the failed emit while the
|
|
426
|
+
// /cancel handler (seeing deliveryStarted) declines to emit its own
|
|
427
|
+
// terminal, leaving the task with no terminal event at all.
|
|
428
|
+
if (!promptState.deliveryStarted) {
|
|
429
|
+
await this.settleCancelRequested(promptState);
|
|
430
|
+
}
|
|
431
|
+
if (err instanceof ChannelLoopSkippedError && !promptState.cancelled) {
|
|
432
|
+
this.emitTaskCancellation(promptState, sessionId, err.reason);
|
|
433
|
+
promptState.cancelled = true;
|
|
434
|
+
}
|
|
435
|
+
if (!promptState.cancelled &&
|
|
436
|
+
!(err instanceof ChannelLoopSkippedError)) {
|
|
437
|
+
releaseHeldChunks();
|
|
438
|
+
this.emitTaskLifecycle({
|
|
439
|
+
...this.lifecycleBase(job.target.chatId, sessionId, job.id),
|
|
440
|
+
type: 'failed',
|
|
441
|
+
error: this.lifecycleError(err),
|
|
442
|
+
phase: promptState.deliveryStarted ? 'delivery' : 'agent',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
else if (promptState.cancelled &&
|
|
446
|
+
!(err instanceof ChannelLoopSkippedError) &&
|
|
447
|
+
!(err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE)) {
|
|
448
|
+
const channel = sanitizeLogText(this.name, 64);
|
|
449
|
+
const safeJobId = sanitizeLogText(job.id, 64);
|
|
450
|
+
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
451
|
+
process.stderr.write(`[${channel}] loop ${safeJobId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
243
455
|
finally {
|
|
244
456
|
promptBridge.off('textChunk', onChunk);
|
|
245
457
|
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
@@ -291,16 +503,17 @@ export class ChannelBase {
|
|
|
291
503
|
prompt,
|
|
292
504
|
new Promise((_, reject) => {
|
|
293
505
|
timer = setTimeout(() => {
|
|
294
|
-
reject(new Error(
|
|
506
|
+
reject(new Error(LOOP_TIMED_OUT_MESSAGE));
|
|
295
507
|
}, timeoutMs);
|
|
296
508
|
timer.unref?.();
|
|
297
509
|
}),
|
|
298
510
|
]);
|
|
299
511
|
}
|
|
300
512
|
catch (err) {
|
|
301
|
-
if (err instanceof Error && err.message ===
|
|
513
|
+
if (err instanceof Error && err.message === LOOP_TIMED_OUT_MESSAGE) {
|
|
302
514
|
promptState.cancelled = true;
|
|
303
515
|
await this.cancelTimedOutLoopPrompt(promptBridge, sessionId, jobId);
|
|
516
|
+
this.emitTaskCancellation(promptState, sessionId, 'timeout');
|
|
304
517
|
}
|
|
305
518
|
throw err;
|
|
306
519
|
}
|
|
@@ -331,6 +544,72 @@ export class ChannelBase {
|
|
|
331
544
|
clearTimeout(graceTimer);
|
|
332
545
|
}
|
|
333
546
|
}
|
|
547
|
+
requestActivePromptCancellation(sessionId, reason = 'cancel_command') {
|
|
548
|
+
const active = this.activePrompts.get(sessionId);
|
|
549
|
+
if (!active) {
|
|
550
|
+
return this.bridge.cancelSession(sessionId).then(() => true, (err) => {
|
|
551
|
+
this.logCancelSessionFailure(sessionId, err);
|
|
552
|
+
return false;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (active.deliveryStarted) {
|
|
556
|
+
return Promise.resolve(false);
|
|
557
|
+
}
|
|
558
|
+
const cancelRequested = active.cancelRequested ??
|
|
559
|
+
this.bridge.cancelSession(sessionId).then(() => true, (err) => {
|
|
560
|
+
this.logCancelSessionFailure(sessionId, err);
|
|
561
|
+
active.cancelRequested = undefined;
|
|
562
|
+
return false;
|
|
563
|
+
});
|
|
564
|
+
active.cancelRequested = cancelRequested;
|
|
565
|
+
active.cancelPending = true;
|
|
566
|
+
return cancelRequested
|
|
567
|
+
.finally(() => {
|
|
568
|
+
active.cancelPending = false;
|
|
569
|
+
})
|
|
570
|
+
.then((cancelSucceeded) => {
|
|
571
|
+
// Re-check after the await: while the cancel RPC was in flight the
|
|
572
|
+
// turn may have started delivery, or ended on its own (uncancelled) —
|
|
573
|
+
// claiming success then would emit a spurious cancelled event for a
|
|
574
|
+
// response the user received. A turn that ended already-cancelled
|
|
575
|
+
// (the abort landed) still counts as a successful cancel.
|
|
576
|
+
const turnEnded = this.activePrompts.get(sessionId) !== active;
|
|
577
|
+
if (!cancelSucceeded ||
|
|
578
|
+
active.deliveryStarted ||
|
|
579
|
+
(turnEnded && !active.cancelled)) {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
active.cancelled = true;
|
|
583
|
+
this.stopActiveStreaming(active, sessionId, reason);
|
|
584
|
+
this.collectBuffers.delete(sessionId);
|
|
585
|
+
this.emitTaskCancellation(active, sessionId, reason);
|
|
586
|
+
return true;
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
logCancelSessionFailure(sessionId, err) {
|
|
590
|
+
process.stderr.write(`[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
|
|
591
|
+
}
|
|
592
|
+
async settleCancelRequested(active) {
|
|
593
|
+
if (!active.cancelRequested || active.cancelled) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
let timer;
|
|
597
|
+
try {
|
|
598
|
+
const cancelled = await Promise.race([
|
|
599
|
+
active.cancelRequested,
|
|
600
|
+
new Promise((resolve) => {
|
|
601
|
+
timer = setTimeout(() => resolve(false), CLEAR_CANCEL_TIMEOUT_MS);
|
|
602
|
+
timer.unref?.();
|
|
603
|
+
}),
|
|
604
|
+
]);
|
|
605
|
+
if (cancelled) {
|
|
606
|
+
active.cancelled = true;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
clearTimeout(timer);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
334
613
|
onToolCall(_chatId, _event) { }
|
|
335
614
|
onSessionDied(sessionId) {
|
|
336
615
|
this.router.removeSessionId(sessionId);
|
|
@@ -397,22 +676,12 @@ export class ChannelBase {
|
|
|
397
676
|
await this.sendMessage(envelope.chatId, 'No request is currently running.');
|
|
398
677
|
return true;
|
|
399
678
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
active.cancelRequested = cancelRequested;
|
|
407
|
-
const cancelSucceeded = await cancelRequested;
|
|
408
|
-
if (!cancelSucceeded) {
|
|
409
|
-
await this.sendMessage(envelope.chatId, 'Failed to cancel current request.');
|
|
410
|
-
return true;
|
|
411
|
-
}
|
|
412
|
-
active.cancelled = true;
|
|
413
|
-
active.stopStreaming?.();
|
|
414
|
-
this.collectBuffers.delete(activeSessionId);
|
|
415
|
-
await this.sendMessage(envelope.chatId, 'Cancelled current request.');
|
|
679
|
+
// Single cancel state machine: adapter stop buttons and /cancel share
|
|
680
|
+
// requestActivePromptCancellation so the two paths cannot drift.
|
|
681
|
+
const cancelSucceeded = await this.requestActivePromptCancellation(activeSessionId, 'cancel_command');
|
|
682
|
+
await this.sendMessage(envelope.chatId, cancelSucceeded
|
|
683
|
+
? 'Cancelled current request.'
|
|
684
|
+
: 'Failed to cancel current request.');
|
|
416
685
|
return true;
|
|
417
686
|
});
|
|
418
687
|
}
|
|
@@ -471,7 +740,7 @@ export class ChannelBase {
|
|
|
471
740
|
// onPromptEnd anyway. Letting the purge proceed makes the turn
|
|
472
741
|
// non-current, so the clearEvicted guard then skips correctly.
|
|
473
742
|
try {
|
|
474
|
-
this.onPromptEnd(active.chatId, id, active.messageId);
|
|
743
|
+
this.onPromptEnd(active.chatId, id, active.loopPrompt ? undefined : active.messageId);
|
|
475
744
|
}
|
|
476
745
|
catch (err) {
|
|
477
746
|
process.stderr.write(`[${this.name}] onPromptEnd threw during /clear eviction for session ${id}: ${err instanceof Error ? err.message : err}\n`);
|
|
@@ -557,6 +826,14 @@ export class ChannelBase {
|
|
|
557
826
|
: '';
|
|
558
827
|
await this.sendMessage(envelope.chatId, [
|
|
559
828
|
`Channel: ${this.name}`,
|
|
829
|
+
// Identity/memory lines only for channels that opted in — keep
|
|
830
|
+
// unconfigured channels' output unchanged.
|
|
831
|
+
...(this.shouldPrependChannelBoundaryPrompt()
|
|
832
|
+
? [
|
|
833
|
+
`Identity: ${sanitizeQuotedText(this.identity.displayName, 128)}`,
|
|
834
|
+
`Memory: ${sanitizeQuotedText(this.memoryScope.namespace, 128)}`,
|
|
835
|
+
]
|
|
836
|
+
: []),
|
|
560
837
|
// Only the basename — don't leak the absolute cwd to group members.
|
|
561
838
|
`Workspace: ${basename(this.config.cwd)}`,
|
|
562
839
|
`Session: ${active ? 'active' : 'none'}${scopeNote}`,
|
|
@@ -705,6 +982,12 @@ export class ChannelBase {
|
|
|
705
982
|
`Session: ${hasSession ? 'active' : 'none'}`,
|
|
706
983
|
`Access: ${policy}`,
|
|
707
984
|
`Channel: ${this.name}`,
|
|
985
|
+
...(this.shouldPrependChannelBoundaryPrompt()
|
|
986
|
+
? [
|
|
987
|
+
`Identity: ${sanitizeQuotedText(this.identity.id, 128)}`,
|
|
988
|
+
`Memory: ${this.memoryScope.mode}`,
|
|
989
|
+
]
|
|
990
|
+
: []),
|
|
708
991
|
];
|
|
709
992
|
await this.sendMessage(envelope.chatId, lines.join('\n'));
|
|
710
993
|
return true;
|
|
@@ -1036,6 +1319,7 @@ export class ChannelBase {
|
|
|
1036
1319
|
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
1037
1320
|
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (clear/await): ${err instanceof Error ? err.message : err}\n`);
|
|
1038
1321
|
});
|
|
1322
|
+
this.emitTaskCancellation(active, sessionId, 'clear');
|
|
1039
1323
|
let timer;
|
|
1040
1324
|
const settled = await Promise.race([
|
|
1041
1325
|
active.done.then(() => true),
|
|
@@ -1469,14 +1753,21 @@ export class ChannelBase {
|
|
|
1469
1753
|
// turn that runs without waiting for a wedged predecessor) is the
|
|
1470
1754
|
// deferred fix — it needs an API change across every adapter and is out
|
|
1471
1755
|
// of scope for this phase (wenshao option (b)).
|
|
1756
|
+
const firstCancellation = !active.cancelled;
|
|
1472
1757
|
active.cancelled = true;
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1758
|
+
if (firstCancellation) {
|
|
1759
|
+
process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
|
|
1760
|
+
this.stopActiveStreaming(active, sessionId, 'steer');
|
|
1761
|
+
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
1762
|
+
// best-effort cancel that fails isn't silently invisible to operators.
|
|
1763
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
1764
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
|
|
1765
|
+
});
|
|
1766
|
+
// Emitted before the bridge cancel settles: steer supersedes the
|
|
1767
|
+
// turn at the channel level (cancelled is already set above), so
|
|
1768
|
+
// the event reflects that intent, not the bridge RPC outcome.
|
|
1769
|
+
this.emitTaskCancellation(active, sessionId, 'steer');
|
|
1770
|
+
}
|
|
1480
1771
|
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
1481
1772
|
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
1482
1773
|
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
@@ -1557,6 +1848,11 @@ export class ChannelBase {
|
|
|
1557
1848
|
if (this.config.instructions) {
|
|
1558
1849
|
sessionContext.push(this.config.instructions);
|
|
1559
1850
|
}
|
|
1851
|
+
// Boundary block goes last: recency bias means later instructions win,
|
|
1852
|
+
// and the isolation boundary must not be overridable by operator text.
|
|
1853
|
+
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
1854
|
+
sessionContext.push(this.channelBoundaryPrompt());
|
|
1855
|
+
}
|
|
1560
1856
|
}
|
|
1561
1857
|
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
1562
1858
|
return;
|
|
@@ -1584,7 +1880,18 @@ export class ChannelBase {
|
|
|
1584
1880
|
// (Steer no longer hands a still-active session to a replacement; only
|
|
1585
1881
|
// /clear evicts, and it gives the next turn a fresh session.)
|
|
1586
1882
|
this.activePrompts.set(sessionId, promptState);
|
|
1587
|
-
this.
|
|
1883
|
+
this.emitTaskLifecycle({
|
|
1884
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
1885
|
+
type: 'started',
|
|
1886
|
+
});
|
|
1887
|
+
// Guarded: an adapter indicator failure must not orphan the started
|
|
1888
|
+
// event (no terminal) or leak the activePrompts entry.
|
|
1889
|
+
try {
|
|
1890
|
+
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
1891
|
+
}
|
|
1892
|
+
catch (err) {
|
|
1893
|
+
process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
|
|
1894
|
+
}
|
|
1588
1895
|
const streamer = useBlockStreaming
|
|
1589
1896
|
? new BlockStreamer({
|
|
1590
1897
|
minChars: this.config.blockStreamingChunk?.minChars ?? 400,
|
|
@@ -1594,10 +1901,28 @@ export class ChannelBase {
|
|
|
1594
1901
|
})
|
|
1595
1902
|
: null;
|
|
1596
1903
|
promptState.stopStreaming = () => streamer?.stop();
|
|
1904
|
+
// Chunks arriving while a cancel is PENDING are held here: pushing them
|
|
1905
|
+
// to any visible sink could send output the cancel can't recall. On a
|
|
1906
|
+
// failed cancel they're replayed; on success, discarded.
|
|
1907
|
+
const heldChunks = [];
|
|
1908
|
+
const releaseHeldChunks = () => {
|
|
1909
|
+
for (const held of heldChunks.splice(0)) {
|
|
1910
|
+
this.emitTaskLifecycle({
|
|
1911
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
1912
|
+
type: 'text_chunk',
|
|
1913
|
+
chunk: held,
|
|
1914
|
+
});
|
|
1915
|
+
this.onResponseChunk(envelope.chatId, held, sessionId);
|
|
1916
|
+
streamer?.push(held);
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1597
1919
|
const onChunk = (sid, chunk) => {
|
|
1598
|
-
if (sid
|
|
1599
|
-
|
|
1600
|
-
|
|
1920
|
+
if (sid !== sessionId || promptState.cancelled) {
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
heldChunks.push(chunk);
|
|
1924
|
+
if (!promptState.cancelPending) {
|
|
1925
|
+
releaseHeldChunks();
|
|
1601
1926
|
}
|
|
1602
1927
|
};
|
|
1603
1928
|
const promptBridge = this.bridge;
|
|
@@ -1607,14 +1932,13 @@ export class ChannelBase {
|
|
|
1607
1932
|
imageBase64,
|
|
1608
1933
|
imageMimeType,
|
|
1609
1934
|
});
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
promptState.cancelled = true;
|
|
1614
|
-
}
|
|
1935
|
+
await this.settleCancelRequested(promptState);
|
|
1936
|
+
if (!promptState.cancelled) {
|
|
1937
|
+
releaseHeldChunks();
|
|
1615
1938
|
}
|
|
1616
1939
|
// If cancelled, skip sending the response
|
|
1617
1940
|
if (!promptState.cancelled && response) {
|
|
1941
|
+
promptState.deliveryStarted = true;
|
|
1618
1942
|
if (streamer) {
|
|
1619
1943
|
await streamer.flush();
|
|
1620
1944
|
}
|
|
@@ -1622,6 +1946,41 @@ export class ChannelBase {
|
|
|
1622
1946
|
await this.onResponseComplete(envelope.chatId, response, sessionId);
|
|
1623
1947
|
}
|
|
1624
1948
|
}
|
|
1949
|
+
// Once delivery started the turn's outcome is fixed — don't let a
|
|
1950
|
+
// cancel settling during the send rewrite completed into cancelled.
|
|
1951
|
+
if (!promptState.deliveryStarted) {
|
|
1952
|
+
await this.settleCancelRequested(promptState);
|
|
1953
|
+
}
|
|
1954
|
+
if (!promptState.cancelled && !promptState.cancellationEmitted) {
|
|
1955
|
+
this.emitTaskLifecycle({
|
|
1956
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
1957
|
+
type: 'completed',
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
catch (err) {
|
|
1962
|
+
// Mirror the try path: once delivery started, a late-settling cancel
|
|
1963
|
+
// must not suppress the failed emit (the /cancel handler declines to
|
|
1964
|
+
// emit its own terminal once deliveryStarted is set).
|
|
1965
|
+
if (!promptState.deliveryStarted) {
|
|
1966
|
+
await this.settleCancelRequested(promptState);
|
|
1967
|
+
}
|
|
1968
|
+
if (!promptState.cancelled) {
|
|
1969
|
+
releaseHeldChunks();
|
|
1970
|
+
this.emitTaskLifecycle({
|
|
1971
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
1972
|
+
type: 'failed',
|
|
1973
|
+
error: this.lifecycleError(err),
|
|
1974
|
+
phase: promptState.deliveryStarted ? 'delivery' : 'agent',
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
else {
|
|
1978
|
+
const channel = sanitizeLogText(this.name, 64);
|
|
1979
|
+
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
1980
|
+
const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
|
|
1981
|
+
process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
|
|
1982
|
+
}
|
|
1983
|
+
throw err;
|
|
1625
1984
|
}
|
|
1626
1985
|
finally {
|
|
1627
1986
|
promptBridge.off('textChunk', onChunk);
|