@qwen-code/channel-base 0.24.6 → 0.24.7
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/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +586 -547
- package/dist/ChannelBase.js.map +1 -1
- package/dist/SessionRouter.d.ts +19 -1
- package/dist/SessionRouter.d.ts.map +1 -1
- package/dist/SessionRouter.js +164 -4
- package/dist/SessionRouter.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -904,6 +904,9 @@ export class ChannelBase {
|
|
|
904
904
|
this.router =
|
|
905
905
|
options?.router ||
|
|
906
906
|
new SessionRouter(bridge, config.cwd, config.sessionScope);
|
|
907
|
+
if (config.multiSession && config.sessionRotation) {
|
|
908
|
+
throw new Error('sessionRotation cannot be combined with multiSession.');
|
|
909
|
+
}
|
|
907
910
|
if (config.multiSession) {
|
|
908
911
|
if (config.sessionScope !== 'user') {
|
|
909
912
|
throw new Error(`Channel "${name}" requires sessionScope "user" when multiSession is enabled.`);
|
|
@@ -920,6 +923,28 @@ export class ChannelBase {
|
|
|
920
923
|
onSessionRetiring: (sessionId) => this.onSessionRetiring(sessionId),
|
|
921
924
|
});
|
|
922
925
|
}
|
|
926
|
+
this.router.setChannelRotation(this.name, config.sessionRotation);
|
|
927
|
+
if (config.sessionRotation) {
|
|
928
|
+
this.router.setRotationActivityChecker(this.name, (sessionId) => this.isNamedSessionBusy(sessionId));
|
|
929
|
+
this.router.setRotationListener(this.name, (sessionId, target) => {
|
|
930
|
+
try {
|
|
931
|
+
this.onSessionRetiring(sessionId);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
process.stderr.write(`[${this.name}] Session retirement failed: ${this.lifecycleError(error)}\n`);
|
|
935
|
+
}
|
|
936
|
+
this.cancelBtw(sessionId);
|
|
937
|
+
this.dropCollectBuffer(sessionId);
|
|
938
|
+
this.instructedSessions.delete(sessionId);
|
|
939
|
+
this.unattendedMemorySessions.delete(sessionId);
|
|
940
|
+
this.removePendingPermissionsForSession(sessionId);
|
|
941
|
+
this.sessionQueues.delete(sessionId);
|
|
942
|
+
this.sessionGenerations.delete(sessionId);
|
|
943
|
+
void this.sendThreadMessage(target.chatId, target.threadId, 'This conversation reached its configured limit. Starting a fresh session.').catch((error) => {
|
|
944
|
+
process.stderr.write(`[${this.name}] Failed to announce session rotation: ${this.lifecycleError(error)}\n`);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
}
|
|
923
948
|
this.registerSharedCommands();
|
|
924
949
|
if (this.loopController) {
|
|
925
950
|
bridge.registerChannelLoopToolHandler?.(this.channelLoopToolHandler);
|
|
@@ -1395,7 +1420,9 @@ export class ChannelBase {
|
|
|
1395
1420
|
throw new Error(`Loop ${job.id} target is no longer authorized.`);
|
|
1396
1421
|
}
|
|
1397
1422
|
await this.waitForBridgeRecovery();
|
|
1398
|
-
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup, { routeKey: job.target.messageRoute });
|
|
1423
|
+
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup, { routeKey: job.target.messageRoute, holdForTurn: true });
|
|
1424
|
+
this.reserveQueuedTurn(sessionId);
|
|
1425
|
+
this.router.releaseRoutingLease(sessionId);
|
|
1399
1426
|
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
1400
1427
|
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
1401
1428
|
// Without the delivery-contract sentence the model treats "post X" prompts
|
|
@@ -1606,8 +1633,9 @@ export class ChannelBase {
|
|
|
1606
1633
|
this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, `loop ${job.id}`);
|
|
1607
1634
|
}
|
|
1608
1635
|
});
|
|
1609
|
-
|
|
1610
|
-
|
|
1636
|
+
const tracked = current.finally(() => this.releaseQueuedTurn(sessionId));
|
|
1637
|
+
this.sessionQueues.set(sessionId, tracked.then(() => undefined).catch(() => { }));
|
|
1638
|
+
return tracked;
|
|
1611
1639
|
}
|
|
1612
1640
|
validateWebhookTask(task) {
|
|
1613
1641
|
this.resolveWebhookTaskTarget(task);
|
|
@@ -1639,7 +1667,10 @@ export class ChannelBase {
|
|
|
1639
1667
|
await this.waitForBridgeRecovery();
|
|
1640
1668
|
const sessionId = await this.router.resolve(this.name, target.senderId, target.chatId, target.threadId, this.config.cwd, target.isGroup, {
|
|
1641
1669
|
routingThreadId: this.webhookRoutingThreadId(task, target),
|
|
1670
|
+
holdForTurn: true,
|
|
1642
1671
|
});
|
|
1672
|
+
this.reserveQueuedTurn(sessionId);
|
|
1673
|
+
this.router.releaseRoutingLease(sessionId);
|
|
1643
1674
|
const promptText = buildChannelWebhookPrompt(task, target);
|
|
1644
1675
|
const taskId = `webhook:${task.source}:${task.eventType}`;
|
|
1645
1676
|
const safeTaskId = sanitizeLogText(taskId, 64);
|
|
@@ -1811,8 +1842,9 @@ export class ChannelBase {
|
|
|
1811
1842
|
this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, `webhook ${safeTaskId}`);
|
|
1812
1843
|
}
|
|
1813
1844
|
});
|
|
1814
|
-
|
|
1815
|
-
|
|
1845
|
+
const tracked = current.finally(() => this.releaseQueuedTurn(sessionId));
|
|
1846
|
+
this.sessionQueues.set(sessionId, tracked.then(() => undefined).catch(() => undefined));
|
|
1847
|
+
return await tracked;
|
|
1816
1848
|
}
|
|
1817
1849
|
webhookRoutingThreadId(task, target) {
|
|
1818
1850
|
return `webhook:${task.source}:${target.threadId ?? target.chatId}`;
|
|
@@ -4746,588 +4778,595 @@ export class ChannelBase {
|
|
|
4746
4778
|
}
|
|
4747
4779
|
}
|
|
4748
4780
|
else {
|
|
4749
|
-
sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup, { routeKey: envelope.messageRoute });
|
|
4750
|
-
}
|
|
4751
|
-
const sourceLabel = this.namedSessions
|
|
4752
|
-
? this.sourceLabelForTurn(sessionId, envelope)
|
|
4753
|
-
: undefined;
|
|
4754
|
-
if (this.namedSessions && !sourceLabel) {
|
|
4755
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Could not identify the selected task. Use /sessions, select it again, and retry.`);
|
|
4756
|
-
return;
|
|
4757
|
-
}
|
|
4758
|
-
if (btwQuestion !== undefined) {
|
|
4759
|
-
await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
|
|
4760
|
-
return;
|
|
4781
|
+
sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup, { routeKey: envelope.messageRoute, holdForTurn: true });
|
|
4761
4782
|
}
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
if (cmd && bridgeShellCommand) {
|
|
4769
|
-
try {
|
|
4770
|
-
const result = await bridgeShellCommand(sessionId, cmd);
|
|
4771
|
-
const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
|
|
4772
|
-
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
4773
|
-
const output = result.output
|
|
4774
|
-
? `${fence}\n${result.output}\n${fence}`
|
|
4775
|
-
: '(no output)';
|
|
4776
|
-
const exitLine = result.exitCode !== null && result.exitCode !== 0
|
|
4777
|
-
? `\nExit code: ${result.exitCode}`
|
|
4778
|
-
: '';
|
|
4779
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
|
|
4780
|
-
}
|
|
4781
|
-
catch (error) {
|
|
4782
|
-
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
|
|
4783
|
-
}
|
|
4783
|
+
try {
|
|
4784
|
+
const sourceLabel = this.namedSessions
|
|
4785
|
+
? this.sourceLabelForTurn(sessionId, envelope)
|
|
4786
|
+
: undefined;
|
|
4787
|
+
if (this.namedSessions && !sourceLabel) {
|
|
4788
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Could not identify the selected task. Use /sessions, select it again, and retry.`);
|
|
4784
4789
|
return;
|
|
4785
4790
|
}
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
!envelope.alreadyPrefixed &&
|
|
4814
|
-
!recognizedSlashCommand) {
|
|
4815
|
-
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
4816
|
-
promptText = `[${who}] ${sanitizePromptText(promptText)}`;
|
|
4817
|
-
// Render the non-bot mention marker AFTER sanitization (like the
|
|
4818
|
-
// [Replying to:] wrapper below). Inside `text` it would pass through
|
|
4819
|
-
// sanitizePromptText, which strips brackets only on content <=64 chars
|
|
4820
|
-
// and folds its newline — so with IDs included, the delivered format
|
|
4821
|
-
// would depend on the ID list's length. IDs are platform-controlled, so
|
|
4822
|
-
// neutralize them like quoted text before they bypass the sanitizer here.
|
|
4823
|
-
if (envelope.mentionedMemberIds?.length) {
|
|
4824
|
-
const ids = envelope.mentionedMemberIds
|
|
4825
|
-
.map((id) => sanitizeQuotedText(id, 64).trim())
|
|
4826
|
-
// A junk-only ID over the cap truncates to a bare '…' (not
|
|
4827
|
-
// whitespace), which would advertise a phantom member — drop it
|
|
4828
|
-
// like an empty ID.
|
|
4829
|
-
.filter((id) => id.length > 0 && id !== '…');
|
|
4830
|
-
if (ids.length > 0) {
|
|
4831
|
-
const memberLabel = ids.length === 1 ? 'member' : 'members';
|
|
4832
|
-
promptText = `[Mentioned ${ids.length} other group ${memberLabel}: ${ids.join(', ')}]\n\n${promptText}`;
|
|
4791
|
+
if (btwQuestion !== undefined) {
|
|
4792
|
+
await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
4796
|
+
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
4797
|
+
// above, before the session was resolved.
|
|
4798
|
+
if (bangText.startsWith('!')) {
|
|
4799
|
+
const cmd = bangText.slice(1).trim();
|
|
4800
|
+
const bridgeShellCommand = this.bridge.shellCommand;
|
|
4801
|
+
if (cmd && bridgeShellCommand) {
|
|
4802
|
+
try {
|
|
4803
|
+
const result = await bridgeShellCommand(sessionId, cmd);
|
|
4804
|
+
const longestRun = Math.max(0, ...Array.from((result.output || '').matchAll(/`+/g), (m) => m[0].length));
|
|
4805
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
4806
|
+
const output = result.output
|
|
4807
|
+
? `${fence}\n${result.output}\n${fence}`
|
|
4808
|
+
: '(no output)';
|
|
4809
|
+
const exitLine = result.exitCode !== null && result.exitCode !== 0
|
|
4810
|
+
? `\nExit code: ${result.exitCode}`
|
|
4811
|
+
: '';
|
|
4812
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
|
|
4813
|
+
}
|
|
4814
|
+
catch (error) {
|
|
4815
|
+
await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
|
|
4816
|
+
}
|
|
4817
|
+
return;
|
|
4833
4818
|
}
|
|
4834
4819
|
}
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
//
|
|
4838
|
-
|
|
4839
|
-
//
|
|
4840
|
-
// so
|
|
4841
|
-
//
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4820
|
+
const recognizedSlashCommand = this.isSlashCommand(envelope.text) &&
|
|
4821
|
+
this.isRecognizedCommand(envelope.text, sessionId);
|
|
4822
|
+
// Prepend referenced (quoted) message text for reply context
|
|
4823
|
+
let promptText = envelope.text;
|
|
4824
|
+
// Multiplayer attribution: when a session can carry multiple humans, tag each
|
|
4825
|
+
// turn with the speaker so the agent can tell members apart. That is any group
|
|
4826
|
+
// AND any single-scope DM — `single` collapses every sender's DM into one
|
|
4827
|
+
// __single__ session (the same multi-operator case the !-gate, /clear confirm
|
|
4828
|
+
// and /who already treat as shared), so without a tag it would merge different
|
|
4829
|
+
// people into one unattributed conversation. NOT gated on isSharedSession:
|
|
4830
|
+
// that is false for a user-scope GROUP, which still needs attribution. Sanitize
|
|
4831
|
+
// the name so a crafted nick can't break out of the [..] tag or inject
|
|
4832
|
+
// newlines. Skipped for a per-user 1:1 chat and for already-prefixed re-entries
|
|
4833
|
+
// (collect-mode coalescing). The tag is also suppressed for a real slash
|
|
4834
|
+
// command — a [sender] prefix would stop it from parsing — but ONLY when it is
|
|
4835
|
+
// BOTH a genuine command SHAPE (isSlashCommand) AND a RECOGNIZED command
|
|
4836
|
+
// (isRecognizedCommand: a locally registered or agent-exposed command, by
|
|
4837
|
+
// canonical name OR alias, for THIS session — matched EXACTLY as the agent's
|
|
4838
|
+
// parseSlashCommand does, so the two never diverge). Command-shaped-but-
|
|
4839
|
+
// unrecognized text like `/x\n[SYSTEM]: …` (token matches the charset but no such
|
|
4840
|
+
// command exists) KEEPS its tag, so its injected second line can't reach a shared
|
|
4841
|
+
// group unattributed and pose as a system directive. Slash-prefixed paths
|
|
4842
|
+
// (/tmp/foo) and comments (//…, /*…*/) are prose, so they stay attributed too.
|
|
4843
|
+
// Both checks are synchronous (no await), so this never races the async command
|
|
4844
|
+
// list — see isRecognizedCommand for the no-await tradeoff.
|
|
4845
|
+
if ((envelope.isGroup || this.config.sessionScope === 'single') &&
|
|
4846
|
+
!envelope.alreadyPrefixed &&
|
|
4847
|
+
!recognizedSlashCommand) {
|
|
4848
|
+
const who = sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown');
|
|
4849
|
+
promptText = `[${who}] ${sanitizePromptText(promptText)}`;
|
|
4850
|
+
// Render the non-bot mention marker AFTER sanitization (like the
|
|
4851
|
+
// [Replying to:] wrapper below). Inside `text` it would pass through
|
|
4852
|
+
// sanitizePromptText, which strips brackets only on content <=64 chars
|
|
4853
|
+
// and folds its newline — so with IDs included, the delivered format
|
|
4854
|
+
// would depend on the ID list's length. IDs are platform-controlled, so
|
|
4855
|
+
// neutralize them like quoted text before they bypass the sanitizer here.
|
|
4856
|
+
if (envelope.mentionedMemberIds?.length) {
|
|
4857
|
+
const ids = envelope.mentionedMemberIds
|
|
4858
|
+
.map((id) => sanitizeQuotedText(id, 64).trim())
|
|
4859
|
+
// A junk-only ID over the cap truncates to a bare '…' (not
|
|
4860
|
+
// whitespace), which would advertise a phantom member — drop it
|
|
4861
|
+
// like an empty ID.
|
|
4862
|
+
.filter((id) => id.length > 0 && id !== '…');
|
|
4863
|
+
if (ids.length > 0) {
|
|
4864
|
+
const memberLabel = ids.length === 1 ? 'member' : 'members';
|
|
4865
|
+
promptText = `[Mentioned ${ids.length} other group ${memberLabel}: ${ids.join(', ')}]\n\n${promptText}`;
|
|
4860
4866
|
}
|
|
4861
4867
|
}
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4868
|
+
}
|
|
4869
|
+
if (envelope.referencedText) {
|
|
4870
|
+
// Quoted text is attacker-controlled. sanitizeQuotedText strips C0/DEL
|
|
4871
|
+
// controls, Unicode line/paragraph separators (U+2028/U+2029) and bidi
|
|
4872
|
+
// overrides, and the wrapper's own `"[]` delimiters, then caps length -
|
|
4873
|
+
// so a crafted quote can't inject newlines/instructions, close the
|
|
4874
|
+
// [Replying to: "..."] wrapper, flip text direction, or balloon the prompt.
|
|
4875
|
+
const quoted = sanitizeQuotedText(envelope.referencedText, 500);
|
|
4876
|
+
promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
|
|
4877
|
+
}
|
|
4878
|
+
// Resolve attachments: extract images for bridge, append file paths to text
|
|
4879
|
+
let imageBase64 = envelope.imageBase64;
|
|
4880
|
+
let imageMimeType = envelope.imageMimeType;
|
|
4881
|
+
const images = [];
|
|
4882
|
+
if (imageBase64 && imageMimeType) {
|
|
4883
|
+
images.push({ data: imageBase64, mimeType: imageMimeType });
|
|
4884
|
+
}
|
|
4885
|
+
if (envelope.attachments?.length) {
|
|
4886
|
+
const filePaths = [];
|
|
4887
|
+
for (const att of envelope.attachments) {
|
|
4888
|
+
if (att.type === 'image' && att.data) {
|
|
4889
|
+
images.push({ data: att.data, mimeType: att.mimeType });
|
|
4890
|
+
if (!imageBase64) {
|
|
4891
|
+
imageBase64 = att.data;
|
|
4892
|
+
imageMimeType = att.mimeType;
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
else if (att.filePath) {
|
|
4896
|
+
const label = att.type === 'file' ? 'file' : att.type;
|
|
4897
|
+
// The filename is attacker-supplied (e.g. DingTalk), so neutralize both
|
|
4898
|
+
// the human-readable label and the on-disk path as they enter the
|
|
4899
|
+
// prompt. They need DIFFERENT rules: the quoted fileName label is just
|
|
4900
|
+
// prose, so sanitizeQuotedText (which also strips `"[]`) is fine — but
|
|
4901
|
+
// the rendered filePath must stay byte-resolvable. Brackets, quotes and
|
|
4902
|
+
// spaces are VALID, common path chars (e.g. `app/[slug]/page.tsx`), so
|
|
4903
|
+
// stripping them would advertise a path that doesn't exist on disk and
|
|
4904
|
+
// break the agent's read-file tool. sanitizePromptPath preserves them
|
|
4905
|
+
// and removes ONLY what could break/reorder the `saved to:` line
|
|
4906
|
+
// (CR/LF, C0/DEL, Unicode line/para separators, bidi overrides).
|
|
4907
|
+
const name = att.fileName
|
|
4908
|
+
? ` "${sanitizeQuotedText(att.fileName, 128)}"`
|
|
4909
|
+
: '';
|
|
4910
|
+
const renderedPath = sanitizePromptPath(att.filePath);
|
|
4911
|
+
filePaths.push(`User sent a ${label}${name}. It has been saved to: ${renderedPath}`);
|
|
4912
|
+
}
|
|
4913
|
+
}
|
|
4914
|
+
if (filePaths.length > 0) {
|
|
4915
|
+
promptText = promptText + '\n\n' + filePaths.join('\n');
|
|
4879
4916
|
}
|
|
4880
4917
|
}
|
|
4881
|
-
if (
|
|
4882
|
-
promptText =
|
|
4918
|
+
if (envelope.metadata) {
|
|
4919
|
+
promptText =
|
|
4920
|
+
promptText + '\n\n' + sanitizePromptText(envelope.metadata);
|
|
4883
4921
|
}
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4922
|
+
// Resolve dispatch mode: per-group override → channel config → default
|
|
4923
|
+
const groupMode = envelope.isGroup
|
|
4924
|
+
? (this.groupConfigFor(envelope.chatId)?.dispatchMode ??
|
|
4925
|
+
this.groupConfigFor('*')?.dispatchMode)
|
|
4926
|
+
: undefined;
|
|
4927
|
+
const mode = groupMode ?? this.config.dispatchMode ?? 'steer';
|
|
4928
|
+
const active = this.activePrompts.get(sessionId);
|
|
4929
|
+
// Diagnostic watchdog for a steered turn that chains behind a wedged
|
|
4930
|
+
// predecessor. Chain-and-wait (option a) means a hung predecessor bridge.prompt()
|
|
4931
|
+
// silently deadlocks this session with no log; this surfaces that. Armed only in
|
|
4932
|
+
// the steer branch, disarmed as the first statement of the chained `.then()` once
|
|
4933
|
+
// the predecessor's tail resolves. Diagnostic-only — it does NOT touch the
|
|
4934
|
+
// chain-and-wait concurrency invariant.
|
|
4935
|
+
let steerWatchdog;
|
|
4936
|
+
if (active) {
|
|
4937
|
+
// A prompt is already running for this session
|
|
4938
|
+
switch (mode) {
|
|
4939
|
+
case 'collect': {
|
|
4940
|
+
// Buffer the message; it will be coalesced when the active prompt finishes
|
|
4941
|
+
let buffer = this.collectBuffers.get(sessionId);
|
|
4942
|
+
if (!buffer) {
|
|
4943
|
+
buffer = [];
|
|
4944
|
+
this.collectBuffers.set(sessionId, buffer);
|
|
4945
|
+
}
|
|
4946
|
+
buffer.push({ text: promptText, envelope });
|
|
4947
|
+
try {
|
|
4948
|
+
this.onPromptBuffered(envelope.chatId, sessionId, envelope.messageId);
|
|
4949
|
+
}
|
|
4950
|
+
catch (err) {
|
|
4951
|
+
process.stderr.write(`[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
4952
|
+
}
|
|
4953
|
+
return;
|
|
4915
4954
|
}
|
|
4916
|
-
|
|
4917
|
-
|
|
4955
|
+
case 'steer': {
|
|
4956
|
+
// Authorization gate (mirrors /cancel): steer = cancel-running +
|
|
4957
|
+
// send-new, so without this an UNAUTHORIZED member of a shared session —
|
|
4958
|
+
// already blocked from /cancel — could abort another user's running turn
|
|
4959
|
+
// just by sending any normal message, defeating the /cancel restriction.
|
|
4960
|
+
// If not authorized, break out of the steer case: the message is NOT
|
|
4961
|
+
// dropped — it falls through to normal queuing (chains onto the session
|
|
4962
|
+
// queue tail and runs AFTER the active turn) without cancelling it.
|
|
4963
|
+
// isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
|
|
4964
|
+
// and for authorized members, so their steer-cancel is unchanged. Audit
|
|
4965
|
+
// the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
|
|
4966
|
+
// gates surface theirs) so an operator can see WHY a member's messages
|
|
4967
|
+
// queue instead of steering. Operator-level only — a normal message from an
|
|
4968
|
+
// unauthorized member shouldn't get a per-message user-facing rejection.
|
|
4969
|
+
// senderId is a stable platform id, not user-controlled display text.
|
|
4970
|
+
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
4971
|
+
process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
|
|
4972
|
+
break;
|
|
4973
|
+
}
|
|
4974
|
+
// Best-effort cancel the running turn so it winds down sooner, then fall
|
|
4975
|
+
// through to CHAIN this new turn onto the session queue tail (see `prev`
|
|
4976
|
+
// below). The new turn therefore runs ONLY AFTER the old turn's finally
|
|
4977
|
+
// has actually run — onChunk detached, activePrompts cleared, indicator
|
|
4978
|
+
// released — so it never executes concurrently with the turn it
|
|
4979
|
+
// supersedes.
|
|
4980
|
+
//
|
|
4981
|
+
// We deliberately do NOT race a bounded wait and then proceed with a
|
|
4982
|
+
// replacement bridge.prompt() while the old turn is still active: both
|
|
4983
|
+
// bridges key active-prompt tracking AND streamed chunks by sessionId
|
|
4984
|
+
// alone, so a concurrent replacement on one session is bridge-unsafe —
|
|
4985
|
+
// DaemonChannelBridge.prompt() rejects while the prior prompt is still
|
|
4986
|
+
// active (the replacement is silently dropped), and the abandoned turn's
|
|
4987
|
+
// late chunks mix into the replacement's stream (duplicated/stale
|
|
4988
|
+
// output). So a genuinely wedged turn makes its successor WAIT rather
|
|
4989
|
+
// than be force-interrupted. Turn-scoped cancellation/routing (a new
|
|
4990
|
+
// turn that runs without waiting for a wedged predecessor) is the
|
|
4991
|
+
// deferred fix — it needs an API change across every adapter and is out
|
|
4992
|
+
// of scope for this phase (wenshao option (b)).
|
|
4993
|
+
const firstCancellation = !active.cancelled;
|
|
4994
|
+
active.cancelled = true;
|
|
4995
|
+
if (firstCancellation) {
|
|
4996
|
+
process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
|
|
4997
|
+
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
4998
|
+
// best-effort cancel that fails isn't silently invisible to operators.
|
|
4999
|
+
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
5000
|
+
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
|
|
5001
|
+
});
|
|
5002
|
+
// Emitted before the bridge cancel settles: steer supersedes the
|
|
5003
|
+
// turn at the channel level (cancelled is already set above), so
|
|
5004
|
+
// the event reflects that intent, not the bridge RPC outcome.
|
|
5005
|
+
this.emitTaskCancellation(active, sessionId, 'steer');
|
|
5006
|
+
this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
|
|
5007
|
+
}
|
|
5008
|
+
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
5009
|
+
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
5010
|
+
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
5011
|
+
// predecessor settles). This only LOGS; it does not start a replacement or
|
|
5012
|
+
// change concurrency. /clear is the recovery path. unref so a pending timer
|
|
5013
|
+
// never keeps the process alive.
|
|
5014
|
+
steerWatchdog = setTimeout(() => {
|
|
5015
|
+
if (this.activePrompts.get(sessionId) === active) {
|
|
5016
|
+
process.stderr.write(`[${this.name}] steer queued behind active turn for session ${sessionId}: still waiting after ${CLEAR_CANCEL_TIMEOUT_MS}ms (use /clear to recover)\n`);
|
|
5017
|
+
}
|
|
5018
|
+
}, CLEAR_CANCEL_TIMEOUT_MS);
|
|
5019
|
+
steerWatchdog.unref?.();
|
|
5020
|
+
// Prepend a cancellation note so the agent understands context.
|
|
5021
|
+
promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
|
|
5022
|
+
break;
|
|
4918
5023
|
}
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
case 'steer': {
|
|
4922
|
-
// Authorization gate (mirrors /cancel): steer = cancel-running +
|
|
4923
|
-
// send-new, so without this an UNAUTHORIZED member of a shared session —
|
|
4924
|
-
// already blocked from /cancel — could abort another user's running turn
|
|
4925
|
-
// just by sending any normal message, defeating the /cancel restriction.
|
|
4926
|
-
// If not authorized, break out of the steer case: the message is NOT
|
|
4927
|
-
// dropped — it falls through to normal queuing (chains onto the session
|
|
4928
|
-
// queue tail and runs AFTER the active turn) without cancelling it.
|
|
4929
|
-
// isAuthorizedForSharedSession returns true for 1:1/non-shared sessions
|
|
4930
|
-
// and for authorized members, so their steer-cancel is unchanged. Audit
|
|
4931
|
-
// the silent steer→queue downgrade (like the /cancel, /clear, /who, /status
|
|
4932
|
-
// gates surface theirs) so an operator can see WHY a member's messages
|
|
4933
|
-
// queue instead of steering. Operator-level only — a normal message from an
|
|
4934
|
-
// unauthorized member shouldn't get a per-message user-facing rejection.
|
|
4935
|
-
// senderId is a stable platform id, not user-controlled display text.
|
|
4936
|
-
if (!this.isAuthorizedForSharedSession(envelope)) {
|
|
4937
|
-
process.stderr.write(`[${this.name}] steer denied for ${envelope.senderId} in shared session (chat=${sanitizeLogText(envelope.chatId, 64)}); queuing instead\n`);
|
|
5024
|
+
case 'followup': {
|
|
5025
|
+
// Chain onto the session queue (existing sequential behavior)
|
|
4938
5026
|
break;
|
|
4939
5027
|
}
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
// released — so it never executes concurrently with the turn it
|
|
4945
|
-
// supersedes.
|
|
4946
|
-
//
|
|
4947
|
-
// We deliberately do NOT race a bounded wait and then proceed with a
|
|
4948
|
-
// replacement bridge.prompt() while the old turn is still active: both
|
|
4949
|
-
// bridges key active-prompt tracking AND streamed chunks by sessionId
|
|
4950
|
-
// alone, so a concurrent replacement on one session is bridge-unsafe —
|
|
4951
|
-
// DaemonChannelBridge.prompt() rejects while the prior prompt is still
|
|
4952
|
-
// active (the replacement is silently dropped), and the abandoned turn's
|
|
4953
|
-
// late chunks mix into the replacement's stream (duplicated/stale
|
|
4954
|
-
// output). So a genuinely wedged turn makes its successor WAIT rather
|
|
4955
|
-
// than be force-interrupted. Turn-scoped cancellation/routing (a new
|
|
4956
|
-
// turn that runs without waiting for a wedged predecessor) is the
|
|
4957
|
-
// deferred fix — it needs an API change across every adapter and is out
|
|
4958
|
-
// of scope for this phase (wenshao option (b)).
|
|
4959
|
-
const firstCancellation = !active.cancelled;
|
|
4960
|
-
active.cancelled = true;
|
|
4961
|
-
if (firstCancellation) {
|
|
4962
|
-
process.stderr.write(`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`);
|
|
4963
|
-
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
|
|
4964
|
-
// best-effort cancel that fails isn't silently invisible to operators.
|
|
4965
|
-
void this.bridge.cancelSession(sessionId).catch((err) => {
|
|
4966
|
-
process.stderr.write(`[${this.name}] cancelSession failed for session=${sessionId} (steer): ${err instanceof Error ? err.message : err}\n`);
|
|
4967
|
-
});
|
|
4968
|
-
// Emitted before the bridge cancel settles: steer supersedes the
|
|
4969
|
-
// turn at the channel level (cancelled is already set above), so
|
|
4970
|
-
// the event reflects that intent, not the bridge RPC outcome.
|
|
4971
|
-
this.emitTaskCancellation(active, sessionId, 'steer');
|
|
4972
|
-
this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
|
|
5028
|
+
default: {
|
|
5029
|
+
// Exhaustive check — should never happen
|
|
5030
|
+
const _exhaustive = mode;
|
|
5031
|
+
throw new Error(`Unknown dispatch mode: ${_exhaustive}`);
|
|
4973
5032
|
}
|
|
4974
|
-
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
4975
|
-
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
4976
|
-
// bridge.prompt() — surface it (the chained `.then()` clears it once the
|
|
4977
|
-
// predecessor settles). This only LOGS; it does not start a replacement or
|
|
4978
|
-
// change concurrency. /clear is the recovery path. unref so a pending timer
|
|
4979
|
-
// never keeps the process alive.
|
|
4980
|
-
steerWatchdog = setTimeout(() => {
|
|
4981
|
-
if (this.activePrompts.get(sessionId) === active) {
|
|
4982
|
-
process.stderr.write(`[${this.name}] steer queued behind active turn for session ${sessionId}: still waiting after ${CLEAR_CANCEL_TIMEOUT_MS}ms (use /clear to recover)\n`);
|
|
4983
|
-
}
|
|
4984
|
-
}, CLEAR_CANCEL_TIMEOUT_MS);
|
|
4985
|
-
steerWatchdog.unref?.();
|
|
4986
|
-
// Prepend a cancellation note so the agent understands context.
|
|
4987
|
-
promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`;
|
|
4988
|
-
break;
|
|
4989
|
-
}
|
|
4990
|
-
case 'followup': {
|
|
4991
|
-
// Chain onto the session queue (existing sequential behavior)
|
|
4992
|
-
break;
|
|
4993
|
-
}
|
|
4994
|
-
default: {
|
|
4995
|
-
// Exhaustive check — should never happen
|
|
4996
|
-
const _exhaustive = mode;
|
|
4997
|
-
throw new Error(`Unknown dispatch mode: ${_exhaustive}`);
|
|
4998
5033
|
}
|
|
4999
5034
|
}
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
if (shouldPrependSessionContext) {
|
|
5003
|
-
this.instructedSessions.add(sessionId);
|
|
5004
|
-
}
|
|
5005
|
-
// Run the prompt with per-session serialization. followup AND steer both chain
|
|
5006
|
-
// onto the existing queue tail; steer additionally best-effort cancelled the
|
|
5007
|
-
// running turn above so the tail resolves sooner. Chaining (rather than seeding
|
|
5008
|
-
// a fresh Promise.resolve()) is what guarantees this turn never runs while the
|
|
5009
|
-
// turn it supersedes is still active — see the steer branch above.
|
|
5010
|
-
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
5011
|
-
// Fresh turns snapshot the generation at enqueue time. A collected re-entry
|
|
5012
|
-
// keeps the generation captured when its buffer drained, so /clear cannot
|
|
5013
|
-
// resurrect it while preprocessing runs before this queue.
|
|
5014
|
-
const generation = namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
|
|
5015
|
-
if (namedTurn) {
|
|
5016
|
-
namedTurn.claimed = true;
|
|
5017
|
-
}
|
|
5018
|
-
else {
|
|
5019
|
-
this.reserveQueuedTurn(sessionId);
|
|
5020
|
-
}
|
|
5021
|
-
const current = prev.then(async () => {
|
|
5022
|
-
// Disarm the steer watchdog: the predecessor's tail has resolved, so this
|
|
5023
|
-
// chained turn is no longer wedged behind it. No-op when unarmed (the timer is
|
|
5024
|
-
// only set on the steer path).
|
|
5025
|
-
clearTimeout(steerWatchdog);
|
|
5026
|
-
// A /clear (or reset/new) while we were queued bumps the generation; the
|
|
5027
|
-
// captured session is cleared, so don't run the prompt against it.
|
|
5028
|
-
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
5029
|
-
return;
|
|
5030
|
-
}
|
|
5031
|
-
if (!shouldPrependSessionContext &&
|
|
5032
|
-
!this.instructedSessions.has(sessionId)) {
|
|
5033
|
-
shouldPrependSessionContext = true;
|
|
5035
|
+
let shouldPrependSessionContext = !this.instructedSessions.has(sessionId);
|
|
5036
|
+
if (shouldPrependSessionContext) {
|
|
5034
5037
|
this.instructedSessions.add(sessionId);
|
|
5035
5038
|
}
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
5049
|
-
sessionContext.push(this.channelBoundaryPrompt());
|
|
5050
|
-
}
|
|
5039
|
+
// Run the prompt with per-session serialization. followup AND steer both chain
|
|
5040
|
+
// onto the existing queue tail; steer additionally best-effort cancelled the
|
|
5041
|
+
// running turn above so the tail resolves sooner. Chaining (rather than seeding
|
|
5042
|
+
// a fresh Promise.resolve()) is what guarantees this turn never runs while the
|
|
5043
|
+
// turn it supersedes is still active — see the steer branch above.
|
|
5044
|
+
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
5045
|
+
// Fresh turns snapshot the generation at enqueue time. A collected re-entry
|
|
5046
|
+
// keeps the generation captured when its buffer drained, so /clear cannot
|
|
5047
|
+
// resurrect it while preprocessing runs before this queue.
|
|
5048
|
+
const generation = namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
|
|
5049
|
+
if (namedTurn) {
|
|
5050
|
+
namedTurn.claimed = true;
|
|
5051
5051
|
}
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
this
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
this.observeChannelMemoryRecall(recallStartedAt, selection.cache, stale
|
|
5065
|
-
? 'stale'
|
|
5066
|
-
: (selection.result ??
|
|
5067
|
-
(relevantEntries.length > 0 ? 'selected' : 'empty')), relevantEntries.length);
|
|
5068
|
-
if (relevantEntries.length > 0) {
|
|
5069
|
-
recallContext =
|
|
5070
|
-
this.formatRelevantChannelMemoryContext(relevantEntries);
|
|
5071
|
-
}
|
|
5052
|
+
else {
|
|
5053
|
+
this.reserveQueuedTurn(sessionId);
|
|
5054
|
+
}
|
|
5055
|
+
const current = prev.then(async () => {
|
|
5056
|
+
// Disarm the steer watchdog: the predecessor's tail has resolved, so this
|
|
5057
|
+
// chained turn is no longer wedged behind it. No-op when unarmed (the timer is
|
|
5058
|
+
// only set on the steer path).
|
|
5059
|
+
clearTimeout(steerWatchdog);
|
|
5060
|
+
// A /clear (or reset/new) while we were queued bumps the generation; the
|
|
5061
|
+
// captured session is cleared, so don't run the prompt against it.
|
|
5062
|
+
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
5063
|
+
return;
|
|
5072
5064
|
}
|
|
5073
|
-
|
|
5074
|
-
this.
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
this.logChannelMemoryError('read', envelope, 'entry listing failed');
|
|
5065
|
+
if (!shouldPrependSessionContext &&
|
|
5066
|
+
!this.instructedSessions.has(sessionId)) {
|
|
5067
|
+
shouldPrependSessionContext = true;
|
|
5068
|
+
this.instructedSessions.add(sessionId);
|
|
5078
5069
|
}
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5070
|
+
const sessionContext = [];
|
|
5071
|
+
if (shouldPrependSessionContext) {
|
|
5072
|
+
if (this.config.instructions) {
|
|
5073
|
+
sessionContext.push(this.config.instructions);
|
|
5074
|
+
}
|
|
5075
|
+
const routeInstructions = envelope.messageRoute === undefined
|
|
5076
|
+
? undefined
|
|
5077
|
+
: this.messageRoutes.get(envelope.messageRoute);
|
|
5078
|
+
if (routeInstructions)
|
|
5079
|
+
sessionContext.push(routeInstructions);
|
|
5080
|
+
// Boundary block goes last: recency bias means later instructions win,
|
|
5081
|
+
// and the isolation boundary must not be overridable by operator text.
|
|
5082
|
+
if (this.shouldPrependChannelBoundaryPrompt()) {
|
|
5083
|
+
sessionContext.push(this.channelBoundaryPrompt());
|
|
5084
|
+
}
|
|
5083
5085
|
}
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
const promptState = {
|
|
5112
|
-
runId: randomUUID(),
|
|
5113
|
-
owner: {
|
|
5114
|
-
kind: 'channel_user',
|
|
5115
|
-
id: envelope.senderId,
|
|
5116
|
-
},
|
|
5117
|
-
cancelled: false,
|
|
5118
|
-
done,
|
|
5119
|
-
resolve: doneResolve,
|
|
5120
|
-
chatId: envelope.chatId,
|
|
5121
|
-
threadId: envelope.threadId,
|
|
5122
|
-
isGroup: envelope.isGroup,
|
|
5123
|
-
messageId: envelope.messageId,
|
|
5124
|
-
senderId: envelope.senderId,
|
|
5125
|
-
senderName: envelope.senderName,
|
|
5126
|
-
metadata: envelope.metadata,
|
|
5127
|
-
sourceLabel,
|
|
5128
|
-
};
|
|
5129
|
-
// This turn is now the single owner of the session's active-prompt slot.
|
|
5130
|
-
// (Steer no longer hands a still-active session to a replacement; only
|
|
5131
|
-
// /clear evicts, and it gives the next turn a fresh session.)
|
|
5132
|
-
this.activePrompts.set(sessionId, promptState);
|
|
5133
|
-
this.emitTaskLifecycle({
|
|
5134
|
-
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5135
|
-
type: 'started',
|
|
5136
|
-
});
|
|
5137
|
-
// Guarded: an adapter indicator failure must not orphan the started
|
|
5138
|
-
// event (no terminal) or leak the activePrompts entry.
|
|
5139
|
-
try {
|
|
5140
|
-
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
5141
|
-
}
|
|
5142
|
-
catch (err) {
|
|
5143
|
-
process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
|
|
5144
|
-
}
|
|
5145
|
-
// Chunks arriving while a cancel is PENDING are held here: pushing them
|
|
5146
|
-
// to any visible sink could send output the cancel can't recall. On a
|
|
5147
|
-
// failed cancel they're replayed; on success, discarded.
|
|
5148
|
-
const heldChunks = [];
|
|
5149
|
-
const releaseHeldChunks = () => {
|
|
5150
|
-
for (const held of heldChunks.splice(0)) {
|
|
5151
|
-
const segment = this.ensureOutputSegment(sessionId, promptState);
|
|
5152
|
-
this.emitTaskLifecycle({
|
|
5153
|
-
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5154
|
-
type: 'text_chunk',
|
|
5155
|
-
chunk: held,
|
|
5156
|
-
});
|
|
5157
|
-
this.onResponseChunk(envelope.chatId, held, sessionId, segment);
|
|
5086
|
+
let recallContext;
|
|
5087
|
+
let recallRead;
|
|
5088
|
+
if (!recognizedSlashCommand &&
|
|
5089
|
+
this.channelMemory &&
|
|
5090
|
+
this.shouldInjectChannelMemory()) {
|
|
5091
|
+
const memoryTarget = this.channelMemoryTarget(envelope);
|
|
5092
|
+
recallRead = this.beginChannelMemoryRead(memoryTarget);
|
|
5093
|
+
const recallStartedAt = performance.now();
|
|
5094
|
+
try {
|
|
5095
|
+
const selection = await this.selectRelevantChannelMemory(envelope, memoryTarget, recallRead);
|
|
5096
|
+
const stale = recallRead.generation !== recallRead.state.generation;
|
|
5097
|
+
const relevantEntries = stale ? [] : selection.entries;
|
|
5098
|
+
this.observeChannelMemoryRecall(recallStartedAt, selection.cache, stale
|
|
5099
|
+
? 'stale'
|
|
5100
|
+
: (selection.result ??
|
|
5101
|
+
(relevantEntries.length > 0 ? 'selected' : 'empty')), relevantEntries.length);
|
|
5102
|
+
if (relevantEntries.length > 0) {
|
|
5103
|
+
recallContext =
|
|
5104
|
+
this.formatRelevantChannelMemoryContext(relevantEntries);
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
5107
|
+
catch {
|
|
5108
|
+
this.observeChannelMemoryRecall(recallStartedAt, 'bypass', 'read_error', 0);
|
|
5109
|
+
this.releaseChannelMemoryRead(recallRead);
|
|
5110
|
+
recallRead = undefined;
|
|
5111
|
+
this.logChannelMemoryError('read', envelope, 'entry listing failed');
|
|
5112
|
+
}
|
|
5158
5113
|
}
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5114
|
+
if (this.dropQueuedTurnIfStale(sessionId, generation, envelope)) {
|
|
5115
|
+
if (recallRead) {
|
|
5116
|
+
this.releaseChannelMemoryRead(recallRead);
|
|
5117
|
+
}
|
|
5162
5118
|
return;
|
|
5163
5119
|
}
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5120
|
+
const acceptedRecallContext = recallRead?.generation === recallRead?.state.generation
|
|
5121
|
+
? recallContext
|
|
5122
|
+
: undefined;
|
|
5123
|
+
if (recognizedSlashCommand) {
|
|
5124
|
+
this.forgetPendingGroupHistory(envelope);
|
|
5167
5125
|
}
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5126
|
+
const groupHistoryEntries = recognizedSlashCommand
|
|
5127
|
+
? []
|
|
5128
|
+
: this.drainPendingGroupHistory(envelope);
|
|
5129
|
+
let promptToSend = this.prependGroupHistoryContext(envelope, promptText, groupHistoryEntries);
|
|
5130
|
+
const hiddenContext = [
|
|
5131
|
+
...(acceptedRecallContext ? [acceptedRecallContext] : []),
|
|
5132
|
+
...sessionContext,
|
|
5133
|
+
];
|
|
5134
|
+
if (hiddenContext.length > 0) {
|
|
5135
|
+
promptToSend = `${hiddenContext.join('\n\n')}\n\n${promptToSend}`;
|
|
5174
5136
|
}
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
|
|
5178
|
-
};
|
|
5179
|
-
// Queue wait and memory recall can outlive a bridge crash. Capture the
|
|
5180
|
-
// bridge only after the latest recovery has restored session routing.
|
|
5181
|
-
await this.waitForBridgeRecovery();
|
|
5182
|
-
const promptBridge = this.bridge;
|
|
5183
|
-
promptBridge.on('textChunk', onChunk);
|
|
5184
|
-
promptBridge.on('responseBoundary', onResponseBoundary);
|
|
5185
|
-
let taskResultPartial = false;
|
|
5186
|
-
try {
|
|
5187
|
-
const response = await promptBridge.prompt(sessionId, promptToSend, {
|
|
5188
|
-
...(this.config.outputMode === 'per_task'
|
|
5189
|
-
? {
|
|
5190
|
-
outputMode: 'per_task',
|
|
5191
|
-
onTaskResult: ({ partial }) => {
|
|
5192
|
-
taskResultPartial = partial;
|
|
5193
|
-
},
|
|
5194
|
-
}
|
|
5195
|
-
: {}),
|
|
5196
|
-
...(images.length > 0 ? { images } : {}),
|
|
5197
|
-
imageBase64,
|
|
5198
|
-
imageMimeType,
|
|
5199
|
-
// Session history shows exactly what the model receives. Only the
|
|
5200
|
-
// controls that can reorder or hide rendered text are neutralized.
|
|
5201
|
-
displayText: sanitizeDisplayText(promptToSend),
|
|
5202
|
-
});
|
|
5203
|
-
await this.settleCancelRequested(promptState);
|
|
5204
|
-
if (!promptState.cancelled) {
|
|
5205
|
-
releaseHeldChunks();
|
|
5137
|
+
if (recallRead) {
|
|
5138
|
+
this.releaseChannelMemoryRead(recallRead);
|
|
5206
5139
|
}
|
|
5207
|
-
//
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5140
|
+
// Register this prompt as active
|
|
5141
|
+
let doneResolve = () => { };
|
|
5142
|
+
const done = new Promise((r) => {
|
|
5143
|
+
doneResolve = r;
|
|
5144
|
+
});
|
|
5145
|
+
const promptState = {
|
|
5146
|
+
runId: randomUUID(),
|
|
5147
|
+
owner: {
|
|
5148
|
+
kind: 'channel_user',
|
|
5149
|
+
id: envelope.senderId,
|
|
5150
|
+
},
|
|
5151
|
+
cancelled: false,
|
|
5152
|
+
done,
|
|
5153
|
+
resolve: doneResolve,
|
|
5154
|
+
chatId: envelope.chatId,
|
|
5155
|
+
threadId: envelope.threadId,
|
|
5156
|
+
isGroup: envelope.isGroup,
|
|
5157
|
+
messageId: envelope.messageId,
|
|
5158
|
+
senderId: envelope.senderId,
|
|
5159
|
+
senderName: envelope.senderName,
|
|
5160
|
+
metadata: envelope.metadata,
|
|
5161
|
+
sourceLabel,
|
|
5162
|
+
};
|
|
5163
|
+
// This turn is now the single owner of the session's active-prompt slot.
|
|
5164
|
+
// (Steer no longer hands a still-active session to a replacement; only
|
|
5165
|
+
// /clear evicts, and it gives the next turn a fresh session.)
|
|
5166
|
+
this.activePrompts.set(sessionId, promptState);
|
|
5167
|
+
this.emitTaskLifecycle({
|
|
5168
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5169
|
+
type: 'started',
|
|
5170
|
+
});
|
|
5171
|
+
// Guarded: an adapter indicator failure must not orphan the started
|
|
5172
|
+
// event (no terminal) or leak the activePrompts entry.
|
|
5173
|
+
try {
|
|
5174
|
+
this.onPromptStart(envelope.chatId, sessionId, envelope.messageId);
|
|
5217
5175
|
}
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
if (!promptState.deliveryStarted) {
|
|
5221
|
-
await this.settleCancelRequested(promptState);
|
|
5176
|
+
catch (err) {
|
|
5177
|
+
process.stderr.write(`[${this.name}] onPromptStart threw for session ${sessionId}: ${this.lifecycleError(err)}\n`);
|
|
5222
5178
|
}
|
|
5223
|
-
|
|
5179
|
+
// Chunks arriving while a cancel is PENDING are held here: pushing them
|
|
5180
|
+
// to any visible sink could send output the cancel can't recall. On a
|
|
5181
|
+
// failed cancel they're replayed; on success, discarded.
|
|
5182
|
+
const heldChunks = [];
|
|
5183
|
+
const releaseHeldChunks = () => {
|
|
5184
|
+
for (const held of heldChunks.splice(0)) {
|
|
5185
|
+
const segment = this.ensureOutputSegment(sessionId, promptState);
|
|
5186
|
+
this.emitTaskLifecycle({
|
|
5187
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5188
|
+
type: 'text_chunk',
|
|
5189
|
+
chunk: held,
|
|
5190
|
+
});
|
|
5191
|
+
this.onResponseChunk(envelope.chatId, held, sessionId, segment);
|
|
5192
|
+
}
|
|
5193
|
+
};
|
|
5194
|
+
const onChunk = (sid, chunk) => {
|
|
5195
|
+
if (sid !== sessionId || promptState.cancelled) {
|
|
5196
|
+
return;
|
|
5197
|
+
}
|
|
5198
|
+
heldChunks.push(chunk);
|
|
5199
|
+
if (!promptState.cancelPending) {
|
|
5200
|
+
releaseHeldChunks();
|
|
5201
|
+
}
|
|
5202
|
+
};
|
|
5203
|
+
const onResponseBoundary = (sid) => {
|
|
5204
|
+
if (sid !== sessionId ||
|
|
5205
|
+
promptState.cancelled ||
|
|
5206
|
+
promptState.cancelPending) {
|
|
5207
|
+
return;
|
|
5208
|
+
}
|
|
5209
|
+
heldChunks.length = 0;
|
|
5224
5210
|
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
5225
|
-
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, '
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5211
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
|
|
5212
|
+
};
|
|
5213
|
+
// Queue wait and memory recall can outlive a bridge crash. Capture the
|
|
5214
|
+
// bridge only after the latest recovery has restored session routing.
|
|
5215
|
+
await this.waitForBridgeRecovery();
|
|
5216
|
+
const promptBridge = this.bridge;
|
|
5217
|
+
promptBridge.on('textChunk', onChunk);
|
|
5218
|
+
promptBridge.on('responseBoundary', onResponseBoundary);
|
|
5219
|
+
let taskResultPartial = false;
|
|
5220
|
+
try {
|
|
5221
|
+
const response = await promptBridge.prompt(sessionId, promptToSend, {
|
|
5222
|
+
...(this.config.outputMode === 'per_task'
|
|
5223
|
+
? {
|
|
5224
|
+
outputMode: 'per_task',
|
|
5225
|
+
onTaskResult: ({ partial }) => {
|
|
5226
|
+
taskResultPartial = partial;
|
|
5227
|
+
},
|
|
5228
|
+
}
|
|
5229
|
+
: {}),
|
|
5230
|
+
...(images.length > 0 ? { images } : {}),
|
|
5231
|
+
imageBase64,
|
|
5232
|
+
imageMimeType,
|
|
5233
|
+
// Session history shows exactly what the model receives. Only the
|
|
5234
|
+
// controls that can reorder or hide rendered text are neutralized.
|
|
5235
|
+
displayText: sanitizeDisplayText(promptToSend),
|
|
5229
5236
|
});
|
|
5230
|
-
}
|
|
5231
|
-
}
|
|
5232
|
-
catch (err) {
|
|
5233
|
-
const runtimeCancelled = !promptState.deliveryStarted &&
|
|
5234
|
-
err instanceof ChannelPromptCancelledError;
|
|
5235
|
-
// Mirror the try path: once delivery started, a late-settling cancel
|
|
5236
|
-
// must not suppress the failed emit (the /cancel handler declines to
|
|
5237
|
-
// emit its own terminal once deliveryStarted is set).
|
|
5238
|
-
if (!promptState.deliveryStarted) {
|
|
5239
5237
|
await this.settleCancelRequested(promptState);
|
|
5240
|
-
if (
|
|
5241
|
-
|
|
5238
|
+
if (!promptState.cancelled) {
|
|
5239
|
+
releaseHeldChunks();
|
|
5240
|
+
}
|
|
5241
|
+
// If cancelled, skip sending the response
|
|
5242
|
+
if (!promptState.cancelled && response) {
|
|
5243
|
+
promptState.deliveryStarted = true;
|
|
5244
|
+
const segment = this.ensureOutputSegment(sessionId, promptState);
|
|
5245
|
+
if (segment && taskResultPartial)
|
|
5246
|
+
segment.partial = true;
|
|
5247
|
+
await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
|
|
5248
|
+
if (segment && promptState.activeSegmentId === segment.segmentId) {
|
|
5249
|
+
promptState.activeSegmentId = undefined;
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
5252
|
+
// Once delivery started the turn's outcome is fixed — don't let a
|
|
5253
|
+
// cancel settling during the send rewrite completed into cancelled.
|
|
5254
|
+
if (!promptState.deliveryStarted) {
|
|
5255
|
+
await this.settleCancelRequested(promptState);
|
|
5256
|
+
}
|
|
5257
|
+
if (!promptState.cancelled && !promptState.cancellationEmitted) {
|
|
5258
|
+
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
5259
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
|
|
5260
|
+
this.emitTaskLifecycle({
|
|
5261
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5262
|
+
type: 'completed',
|
|
5263
|
+
});
|
|
5242
5264
|
}
|
|
5243
5265
|
}
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
else {
|
|
5256
|
-
const channel = sanitizeLogText(this.name, 64);
|
|
5257
|
-
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
5258
|
-
const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
|
|
5259
|
-
process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
|
|
5260
|
-
}
|
|
5261
|
-
if (promptState.cancelled || runtimeCancelled) {
|
|
5262
|
-
return;
|
|
5263
|
-
}
|
|
5264
|
-
if (sourceLabel) {
|
|
5265
|
-
this.inboundErrorSourceLabels.set(envelope, sourceLabel);
|
|
5266
|
-
}
|
|
5267
|
-
throw err;
|
|
5268
|
-
}
|
|
5269
|
-
finally {
|
|
5270
|
-
promptBridge.off('textChunk', onChunk);
|
|
5271
|
-
promptBridge.off('responseBoundary', onResponseBoundary);
|
|
5272
|
-
// Identity guard: a turn that wedged past /clear's bounded wait gets
|
|
5273
|
-
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
|
|
5274
|
-
// turn the user starts AFTER the clear can re-seed activePrompts (and own
|
|
5275
|
-
// the collect buffer) for this session. When the wedged bridge.prompt
|
|
5276
|
-
// finally settles and runs this finally, touching session-visible state
|
|
5277
|
-
// would clobber that live later turn — ending the working indicator it
|
|
5278
|
-
// re-seeded or draining a buffer it owns. So only touch session-scoped
|
|
5279
|
-
// state when the entry is still ours. (Steer no longer evicts: it cancels
|
|
5280
|
-
// and waits, so a steered turn is always stillCurrent when it completes.)
|
|
5281
|
-
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
5282
|
-
// onPromptEnd runs platform cleanup (clear the typing interval, recall the
|
|
5283
|
-
// working reaction, finalize the card). Run it UNLESS this turn was a
|
|
5284
|
-
// /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
|
|
5285
|
-
// at clear-time, and a turn the user started after the clear may now own the
|
|
5286
|
-
// chat-scoped indicator, so re-running cleanup here would clobber it.
|
|
5287
|
-
// Invariant: clearEvicted is set ONLY by /clear's eviction, which then
|
|
5288
|
-
// UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
|
|
5289
|
-
// clear-time onPromptEnd guarantees the purge runs even if that throws), and
|
|
5290
|
-
// no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
|
|
5291
|
-
// stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
|
|
5292
|
-
// `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
|
|
5293
|
-
// Steer no longer evicts (it chains and waits), so a steered turn is always
|
|
5294
|
-
// stillCurrent on completion.
|
|
5295
|
-
if (!promptState.clearEvicted) {
|
|
5296
|
-
// onPromptEnd runs platform-adapter cleanup (clear the typing interval,
|
|
5297
|
-
// recall the working reaction, finalize the card) — network/IO that CAN
|
|
5298
|
-
// throw. Guard it like the /clear-eviction path above: an uncaught throw
|
|
5299
|
-
// here would skip activePrompts.delete (session leak), promptState.resolve
|
|
5300
|
-
// (active.done never settles → a later /clear falsely logs "abandoned a
|
|
5301
|
-
// wedged turn" for a turn that completed), and the collect-buffer drain
|
|
5302
|
-
// (lost messages) — and the rejected queue-chain promise, swallowed by the
|
|
5303
|
-
// tail .catch(() => {}), would silently drop every later turn this session.
|
|
5304
|
-
try {
|
|
5305
|
-
this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
|
|
5266
|
+
catch (err) {
|
|
5267
|
+
const runtimeCancelled = !promptState.deliveryStarted &&
|
|
5268
|
+
err instanceof ChannelPromptCancelledError;
|
|
5269
|
+
// Mirror the try path: once delivery started, a late-settling cancel
|
|
5270
|
+
// must not suppress the failed emit (the /cancel handler declines to
|
|
5271
|
+
// emit its own terminal once deliveryStarted is set).
|
|
5272
|
+
if (!promptState.deliveryStarted) {
|
|
5273
|
+
await this.settleCancelRequested(promptState);
|
|
5274
|
+
if (runtimeCancelled) {
|
|
5275
|
+
this.emitTaskCancellation(promptState, sessionId, 'runtime_cancelled');
|
|
5276
|
+
}
|
|
5306
5277
|
}
|
|
5307
|
-
|
|
5308
|
-
|
|
5278
|
+
if (!promptState.cancelled && !runtimeCancelled) {
|
|
5279
|
+
releaseHeldChunks();
|
|
5280
|
+
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
5281
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
|
|
5282
|
+
this.emitTaskLifecycle({
|
|
5283
|
+
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
5284
|
+
type: 'failed',
|
|
5285
|
+
error: this.lifecycleError(err),
|
|
5286
|
+
phase: promptState.deliveryStarted ? 'delivery' : 'agent',
|
|
5287
|
+
});
|
|
5288
|
+
}
|
|
5289
|
+
else {
|
|
5290
|
+
const channel = sanitizeLogText(this.name, 64);
|
|
5291
|
+
const safeSessionId = sanitizeLogText(sessionId, 64);
|
|
5292
|
+
const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
|
|
5293
|
+
process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
|
|
5309
5294
|
}
|
|
5295
|
+
if (promptState.cancelled || runtimeCancelled) {
|
|
5296
|
+
return;
|
|
5297
|
+
}
|
|
5298
|
+
if (sourceLabel) {
|
|
5299
|
+
this.inboundErrorSourceLabels.set(envelope, sourceLabel);
|
|
5300
|
+
}
|
|
5301
|
+
throw err;
|
|
5310
5302
|
}
|
|
5311
|
-
|
|
5312
|
-
|
|
5303
|
+
finally {
|
|
5304
|
+
promptBridge.off('textChunk', onChunk);
|
|
5305
|
+
promptBridge.off('responseBoundary', onResponseBoundary);
|
|
5306
|
+
// Identity guard: a turn that wedged past /clear's bounded wait gets
|
|
5307
|
+
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
|
|
5308
|
+
// turn the user starts AFTER the clear can re-seed activePrompts (and own
|
|
5309
|
+
// the collect buffer) for this session. When the wedged bridge.prompt
|
|
5310
|
+
// finally settles and runs this finally, touching session-visible state
|
|
5311
|
+
// would clobber that live later turn — ending the working indicator it
|
|
5312
|
+
// re-seeded or draining a buffer it owns. So only touch session-scoped
|
|
5313
|
+
// state when the entry is still ours. (Steer no longer evicts: it cancels
|
|
5314
|
+
// and waits, so a steered turn is always stillCurrent when it completes.)
|
|
5315
|
+
const stillCurrent = this.activePrompts.get(sessionId) === promptState;
|
|
5316
|
+
// onPromptEnd runs platform cleanup (clear the typing interval, recall the
|
|
5317
|
+
// working reaction, finalize the card). Run it UNLESS this turn was a
|
|
5318
|
+
// /clear eviction (clearEvicted): /clear already ran this turn's onPromptEnd
|
|
5319
|
+
// at clear-time, and a turn the user started after the clear may now own the
|
|
5320
|
+
// chat-scoped indicator, so re-running cleanup here would clobber it.
|
|
5321
|
+
// Invariant: clearEvicted is set ONLY by /clear's eviction, which then
|
|
5322
|
+
// UNCONDITIONALLY deletes activePrompts[sessionId] (its try/catch around the
|
|
5323
|
+
// clear-time onPromptEnd guarantees the purge runs even if that throws), and
|
|
5324
|
+
// no turn ever re-inserts THIS promptState object — so clearEvicted ⟹ NOT
|
|
5325
|
+
// stillCurrent. Hence `stillCurrent || !clearEvicted` reduces to
|
|
5326
|
+
// `!clearEvicted` (the `stillCurrent && clearEvicted` case is unreachable).
|
|
5327
|
+
// Steer no longer evicts (it chains and waits), so a steered turn is always
|
|
5328
|
+
// stillCurrent on completion.
|
|
5329
|
+
if (!promptState.clearEvicted) {
|
|
5330
|
+
// onPromptEnd runs platform-adapter cleanup (clear the typing interval,
|
|
5331
|
+
// recall the working reaction, finalize the card) — network/IO that CAN
|
|
5332
|
+
// throw. Guard it like the /clear-eviction path above: an uncaught throw
|
|
5333
|
+
// here would skip activePrompts.delete (session leak), promptState.resolve
|
|
5334
|
+
// (active.done never settles → a later /clear falsely logs "abandoned a
|
|
5335
|
+
// wedged turn" for a turn that completed), and the collect-buffer drain
|
|
5336
|
+
// (lost messages) — and the rejected queue-chain promise, swallowed by the
|
|
5337
|
+
// tail .catch(() => {}), would silently drop every later turn this session.
|
|
5338
|
+
try {
|
|
5339
|
+
this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId);
|
|
5340
|
+
}
|
|
5341
|
+
catch (err) {
|
|
5342
|
+
process.stderr.write(`[${this.name}] onPromptEnd threw in finally for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
if (stillCurrent) {
|
|
5346
|
+
this.activePrompts.delete(sessionId);
|
|
5347
|
+
}
|
|
5348
|
+
// Signal any /clear waiter racing our done that we're done — even a
|
|
5349
|
+
// /clear-evicted wedged turn must release it (its bounded wait already
|
|
5350
|
+
// timed out). (Steer no longer waits on done; it chains on the queue tail.)
|
|
5351
|
+
promptState.resolve();
|
|
5352
|
+
// Drain collect buffer if any messages accumulated — but only while we're
|
|
5353
|
+
// still the active turn, so a /clear-evicted wedged turn whose bridge.prompt
|
|
5354
|
+
// settles late can't drain a buffer a later turn now owns. (Belt-and-
|
|
5355
|
+
// suspenders: /clear already deletes the buffer on eviction, so this guard
|
|
5356
|
+
// is defensive — but it keeps the invariant "only the current turn drains".)
|
|
5357
|
+
this.drainCollectBufferForCurrentPrompt(sessionId, stillCurrent, 'prompt completion');
|
|
5313
5358
|
}
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
this.
|
|
5324
|
-
|
|
5325
|
-
});
|
|
5326
|
-
const tracked = current.finally(() => {
|
|
5327
|
-
this.releaseQueuedTurn(sessionId);
|
|
5328
|
-
});
|
|
5329
|
-
this.sessionQueues.set(sessionId, tracked.catch(() => { }));
|
|
5330
|
-
await tracked;
|
|
5359
|
+
});
|
|
5360
|
+
const tracked = current.finally(() => {
|
|
5361
|
+
this.releaseQueuedTurn(sessionId);
|
|
5362
|
+
});
|
|
5363
|
+
this.sessionQueues.set(sessionId, tracked.catch(() => { }));
|
|
5364
|
+
await tracked;
|
|
5365
|
+
}
|
|
5366
|
+
finally {
|
|
5367
|
+
if (!this.namedSessions)
|
|
5368
|
+
this.router.releaseRoutingLease(sessionId);
|
|
5369
|
+
}
|
|
5331
5370
|
}
|
|
5332
5371
|
pairingRejectionMessage(rejected) {
|
|
5333
5372
|
return rejected === 'sender_pending'
|