@qwen-code/channel-github 0.21.2 → 0.21.3-nightly.20260803.e1e5b42ce

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.
@@ -1,10 +1,10 @@
1
- import { createHash } from 'node:crypto';
2
- import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
3
- import { join } from 'node:path';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { Octokit } from '@octokit/rest';
6
6
  import { HttpsProxyAgent } from 'https-proxy-agent';
7
- import { getGlobalQwenDir, PollingChannelBase, sanitizeLogText, } from '@qwen-code/channel-base';
7
+ import { getGlobalQwenDir, getWorkspaceScopeDirName, PollingChannelBase, sanitizeLogText, } from '@qwen-code/channel-base';
8
8
  import { testBotMention, stripBotMention } from './mention.js';
9
9
  const KNOWN_NOTIFICATION_REASONS = new Set([
10
10
  'mention',
@@ -45,6 +45,73 @@ function normalizeReasonFilter(config, channelName) {
45
45
  }
46
46
  return reasons.length > 0 ? new Set(reasons) : null;
47
47
  }
48
+ const MAX_INBOUND_TASK_ATTEMPTS = 3;
49
+ function isOptionalStringArray(value) {
50
+ return (value === undefined ||
51
+ (Array.isArray(value) && value.every((item) => typeof item === 'string')));
52
+ }
53
+ function isInboundEnvelope(value) {
54
+ if (value === undefined)
55
+ return true;
56
+ if (value === null || typeof value !== 'object')
57
+ return false;
58
+ const envelope = value;
59
+ return (typeof envelope.channelName === 'string' &&
60
+ typeof envelope.senderId === 'string' &&
61
+ typeof envelope.senderName === 'string' &&
62
+ typeof envelope.chatId === 'string' &&
63
+ typeof envelope.text === 'string' &&
64
+ typeof envelope.isGroup === 'boolean' &&
65
+ typeof envelope.isMentioned === 'boolean' &&
66
+ typeof envelope.isReplyToBot === 'boolean' &&
67
+ (envelope.chatName === undefined ||
68
+ typeof envelope.chatName === 'string') &&
69
+ (envelope.threadId === undefined ||
70
+ typeof envelope.threadId === 'string') &&
71
+ (envelope.messageId === undefined ||
72
+ typeof envelope.messageId === 'string') &&
73
+ (envelope.referencedText === undefined ||
74
+ typeof envelope.referencedText === 'string') &&
75
+ (envelope.imageBase64 === undefined ||
76
+ typeof envelope.imageBase64 === 'string') &&
77
+ (envelope.imageMimeType === undefined ||
78
+ typeof envelope.imageMimeType === 'string') &&
79
+ (envelope.attachments === undefined ||
80
+ Array.isArray(envelope.attachments)) &&
81
+ (envelope.metadata === undefined ||
82
+ typeof envelope.metadata === 'string') &&
83
+ (envelope.alreadyPrefixed === undefined ||
84
+ envelope.alreadyPrefixed === true));
85
+ }
86
+ function isInboundTaskRecord(value) {
87
+ if (value === null || typeof value !== 'object')
88
+ return false;
89
+ const record = value;
90
+ return (record.version === 1 &&
91
+ typeof record.id === 'string' &&
92
+ typeof record.createdAt === 'string' &&
93
+ typeof record.updatedAt === 'string' &&
94
+ ['accepted', 'running', 'reply_pending', 'failed', 'cancelled'].includes(record.state) &&
95
+ Number.isSafeInteger(record.issueNumber) &&
96
+ record.source !== null &&
97
+ typeof record.source === 'object' &&
98
+ typeof record.source.chatId === 'string' &&
99
+ (record.source.threadId === undefined ||
100
+ typeof record.source.threadId === 'string') &&
101
+ (record.source.messageId === undefined ||
102
+ typeof record.source.messageId === 'string') &&
103
+ isInboundEnvelope(record.envelope) &&
104
+ record.dedupe !== null &&
105
+ typeof record.dedupe === 'object' &&
106
+ isOptionalStringArray(record.dedupe.dispatchedBodies) &&
107
+ isOptionalStringArray(record.dedupe.dispatchedComments) &&
108
+ isOptionalStringArray(record.dedupe.dispatchedEvents) &&
109
+ (record.attempts === undefined ||
110
+ (Number.isSafeInteger(record.attempts) && record.attempts >= 0)) &&
111
+ (record.errorCommentPosted === undefined ||
112
+ typeof record.errorCommentPosted === 'boolean') &&
113
+ (record.error === undefined || typeof record.error === 'string'));
114
+ }
48
115
  class FinalPublicationError extends Error {
49
116
  }
50
117
  const NO_REPLY_SENTINEL = '<no-reply/>';
@@ -64,8 +131,8 @@ function isNoReplySentinel(text) {
64
131
  }
65
132
  function isDefiniteNoWriteGithubError(err) {
66
133
  const e = err;
67
- const remaining = Number(e.response?.headers?.['x-ratelimit-remaining']);
68
- return e.status === 429 || (e.status === 403 && remaining === 0);
134
+ return ((e.status === 403 || e.status === 429) &&
135
+ Number(e.response?.headers?.['x-ratelimit-remaining']) === 0);
69
136
  }
70
137
  function parseTriggerKind(metadata) {
71
138
  return metadata?.match(/^Trigger: ([\w-]+)\./m)?.[1];
@@ -79,13 +146,34 @@ function buildTriggerGuidance(reason) {
79
146
  }
80
147
  return `For ${reason}, output exactly ${NO_REPLY_SENTINEL} when a public reply is unnecessary.`;
81
148
  }
149
+ function isPendingFinalDelivery(value) {
150
+ const item = value;
151
+ return (item !== null &&
152
+ typeof item === 'object' &&
153
+ typeof item.id === 'string' &&
154
+ typeof item.createdAt === 'string' &&
155
+ typeof item.chatId === 'string' &&
156
+ typeof item.threadId === 'string' &&
157
+ typeof item.fullText === 'string' &&
158
+ typeof item.sessionId === 'string');
159
+ }
82
160
  export class GithubChannel extends PollingChannelBase {
83
161
  octokit;
84
162
  botUsername = null;
85
163
  webOrigin = 'https://github.com';
86
164
  activeReactions = new Map();
87
165
  reactionsPendingRemoval = new Set();
166
+ pendingFinalDeliveryRetry;
167
+ pendingFinalDeliveryRetryAbort;
168
+ pendingFinalDeliveryRequestsActive = 0;
169
+ pendingFinalDeliveryRetryStopRequested = false;
88
170
  reasonFilter = null;
171
+ inboundRecoveryPending = true;
172
+ recoverableInboundTasks = 0;
173
+ activeInboundTaskIdsByMessage = new Map();
174
+ cancelledInboundTaskIds = new Set();
175
+ pendingCursorUpdatedAt;
176
+ inboundPersistenceBlocked = false;
89
177
  constructor(name, config, bridge, options) {
90
178
  config.blockStreaming = 'off';
91
179
  config.instructions = [
@@ -157,10 +245,42 @@ export class GithubChannel extends PollingChannelBase {
157
245
  process.stderr.write(`[Channel:${this.name}] warning: authenticated GitHub account "${this.botUsername}" is allowlisted but cannot trigger this channel; use a separate operator account.\n`);
158
246
  }
159
247
  this.gate.replaceAllowedUsers(allowed);
248
+ this.migrateLegacyPublicationState();
249
+ this.inboundPersistenceBlocked = false;
250
+ this.inboundRecoveryPending = true;
251
+ try {
252
+ this.recoverableInboundTasks = this.readInboundTasks().filter((record) => this.isRecoverableInboundTask(record)).length;
253
+ }
254
+ catch {
255
+ this.recoverableInboundTasks = 0;
256
+ }
257
+ this.pendingFinalDeliveryRetryStopRequested = false;
160
258
  this.startPollLoop();
259
+ if (this.pendingFinalDeliveryRetry) {
260
+ return;
261
+ }
262
+ const retryAbort = new AbortController();
263
+ const retry = this.retryPendingFinalDeliveries(retryAbort.signal)
264
+ .catch((err) => {
265
+ if (!retryAbort.signal.aborted) {
266
+ process.stderr.write(`[Channel:${this.name}] pending GitHub delivery retry failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
267
+ }
268
+ })
269
+ .finally(() => {
270
+ if (this.pendingFinalDeliveryRetry === retry) {
271
+ this.pendingFinalDeliveryRetry = undefined;
272
+ this.pendingFinalDeliveryRetryAbort = undefined;
273
+ }
274
+ });
275
+ this.pendingFinalDeliveryRetryAbort = retryAbort;
276
+ this.pendingFinalDeliveryRetry = retry;
161
277
  }
162
278
  disconnect() {
163
279
  this.stopPollLoop();
280
+ this.pendingFinalDeliveryRetryStopRequested = true;
281
+ if (this.pendingFinalDeliveryRequestsActive === 0) {
282
+ this.pendingFinalDeliveryRetryAbort?.abort();
283
+ }
164
284
  }
165
285
  async sendMessage(_chatId, _text) {
166
286
  throw new Error(`[Channel:${this.name}] sendMessage requires a threadId; use sendThreadMessage`);
@@ -174,18 +294,15 @@ export class GithubChannel extends PollingChannelBase {
174
294
  async publishFinalResponse(chatId, threadId, fullText, sessionId) {
175
295
  const threadMatch = threadId?.match(/^(issue|pr):(\d+)$/);
176
296
  const metadata = this.getResponseMetadata(sessionId);
177
- const auditBase = {
178
- channel: this.name,
179
- triggerKind: parseTriggerKind(metadata),
180
- repository: chatId,
181
- number: threadMatch ? Number(threadMatch[2]) : undefined,
297
+ const auditBase = this.buildPublicationAuditBase({
298
+ chatId,
299
+ threadId,
300
+ fullText,
182
301
  sessionId,
183
302
  sourceMessageId: this.getResponseMessageId(sessionId),
184
303
  actor: this.getResponseSenderId(sessionId),
185
- threadId,
186
- bodySha256: createHash('sha256').update(fullText).digest('hex'),
187
- bodyChars: Array.from(fullText).length,
188
- };
304
+ metadata,
305
+ });
189
306
  if (isNoReplySentinel(fullText)) {
190
307
  this.recordPublicationAudit({
191
308
  ...auditBase,
@@ -201,6 +318,12 @@ export class GithubChannel extends PollingChannelBase {
201
318
  if (!threadMatch) {
202
319
  throw new Error(`[Channel:${this.name}] invalid threadId format: ${threadId}`);
203
320
  }
321
+ this.recordPublicationAudit({
322
+ ...auditBase,
323
+ at: new Date().toISOString(),
324
+ type: 'github_publication',
325
+ outcome: 'posting',
326
+ });
204
327
  try {
205
328
  const comment = await this.createIssueComment(chatId, threadId, fullText, 3, isDefiniteNoWriteGithubError);
206
329
  this.recordPublicationAudit({
@@ -221,10 +344,213 @@ export class GithubChannel extends PollingChannelBase {
221
344
  failurePhase: 'delivery',
222
345
  failureError: sanitizeLogText(err instanceof Error ? err.message : String(err), 200),
223
346
  });
347
+ if (threadId && isDefiniteNoWriteGithubError(err)) {
348
+ try {
349
+ this.enqueuePendingFinalDelivery({
350
+ ...auditBase,
351
+ chatId,
352
+ threadId,
353
+ fullText,
354
+ });
355
+ }
356
+ catch (persistError) {
357
+ throw new Error(`[Channel:${this.name}] failed to persist pending GitHub delivery: ${sanitizeLogText(persistError instanceof Error
358
+ ? persistError.message
359
+ : String(persistError), 200)}`, { cause: persistError });
360
+ }
361
+ }
224
362
  throw new FinalPublicationError(err instanceof Error ? err.message : String(err), { cause: err });
225
363
  }
226
364
  }
227
- async createIssueComment(chatId, threadId, text, retries = 3, shouldRetry = () => true) {
365
+ buildPublicationAuditBase(input) {
366
+ const threadMatch = input.threadId?.match(/^(issue|pr):(\d+)$/);
367
+ return {
368
+ channel: this.name,
369
+ triggerKind: parseTriggerKind(input.metadata),
370
+ repository: input.chatId,
371
+ number: threadMatch ? Number(threadMatch[2]) : undefined,
372
+ sessionId: input.sessionId,
373
+ sourceMessageId: input.sourceMessageId,
374
+ actor: input.actor,
375
+ threadId: input.threadId,
376
+ bodySha256: createHash('sha256').update(input.fullText).digest('hex'),
377
+ bodyChars: Array.from(input.fullText).length,
378
+ };
379
+ }
380
+ enqueuePendingFinalDelivery(input) {
381
+ const record = {
382
+ id: createHash('sha256')
383
+ .update(JSON.stringify([
384
+ input.chatId,
385
+ input.threadId,
386
+ input.sessionId,
387
+ input.sourceMessageId ?? randomUUID(),
388
+ input.bodySha256,
389
+ ]))
390
+ .digest('hex'),
391
+ createdAt: new Date().toISOString(),
392
+ chatId: input.chatId,
393
+ threadId: input.threadId,
394
+ fullText: input.fullText,
395
+ sessionId: input.sessionId,
396
+ sourceMessageId: input.sourceMessageId,
397
+ actor: input.actor,
398
+ triggerKind: input.triggerKind,
399
+ };
400
+ const pending = this.readPendingFinalDeliveries(true).filter((item) => item.id !== record.id);
401
+ this.writePendingFinalDeliveries([...pending, record]);
402
+ }
403
+ async retryPendingFinalDeliveries(signal) {
404
+ const pending = this.readPendingFinalDeliveries();
405
+ for (const record of pending) {
406
+ if (signal?.aborted)
407
+ return;
408
+ const auditBase = this.buildPublicationAuditBase({
409
+ chatId: record.chatId,
410
+ threadId: record.threadId,
411
+ fullText: record.fullText,
412
+ sessionId: record.sessionId,
413
+ sourceMessageId: record.sourceMessageId,
414
+ actor: record.actor,
415
+ metadata: record.triggerKind
416
+ ? `Trigger: ${record.triggerKind}.`
417
+ : undefined,
418
+ });
419
+ if (this.hasPostedPublicationAudit(record.id)) {
420
+ process.stderr.write(`[Channel:${this.name}] dropping pending GitHub delivery already recorded as posted: ${sanitizeLogText(record.id, 80)}\n`);
421
+ if (this.updatePendingFinalDeliveries((current) => current.filter((item) => item.id !== record.id))) {
422
+ this.removeReplyPendingInboundTask(record);
423
+ }
424
+ continue;
425
+ }
426
+ try {
427
+ const comment = await this.createIssueComment(record.chatId, record.threadId, record.fullText, 3, isDefiniteNoWriteGithubError, signal);
428
+ // ponytail: GitHub has no createComment idempotency key without adding
429
+ // a public marker to the verbatim final body; marker-upsert if that
430
+ // contract changes.
431
+ this.recordPublicationAudit({
432
+ ...auditBase,
433
+ at: new Date().toISOString(),
434
+ type: 'github_publication',
435
+ outcome: 'posted',
436
+ pendingId: record.id,
437
+ commentId: comment.id,
438
+ commentUrl: comment.html_url,
439
+ });
440
+ if (!this.updatePendingFinalDeliveries((current) => current.filter((item) => item.id !== record.id))) {
441
+ continue;
442
+ }
443
+ this.removeReplyPendingInboundTask(record);
444
+ }
445
+ catch (err) {
446
+ if (signal?.aborted)
447
+ return;
448
+ if (isDefiniteNoWriteGithubError(err)) {
449
+ continue;
450
+ }
451
+ this.recordPublicationAudit({
452
+ ...auditBase,
453
+ at: new Date().toISOString(),
454
+ type: 'github_publication',
455
+ outcome: 'failed',
456
+ failurePhase: 'delivery',
457
+ failureError: sanitizeLogText(err instanceof Error ? err.message : String(err), 200),
458
+ });
459
+ if (!this.updatePendingFinalDeliveries((current) => current.filter((item) => item.id !== record.id))) {
460
+ continue;
461
+ }
462
+ this.removeReplyPendingInboundTask(record);
463
+ }
464
+ }
465
+ }
466
+ hasPostedPublicationAudit(pendingId) {
467
+ try {
468
+ return readFileSync(this.channelFilePath('github-audit.jsonl'), 'utf-8')
469
+ .split('\n')
470
+ .some((line) => {
471
+ if (!line)
472
+ return false;
473
+ let record;
474
+ try {
475
+ record = JSON.parse(line);
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ return record.outcome === 'posted' && record.pendingId === pendingId;
481
+ });
482
+ }
483
+ catch {
484
+ return false;
485
+ }
486
+ }
487
+ updatePendingFinalDeliveries(update) {
488
+ try {
489
+ this.writePendingFinalDeliveries(update(this.readPendingFinalDeliveries(true)));
490
+ return true;
491
+ }
492
+ catch (err) {
493
+ process.stderr.write(`[Channel:${this.name}] failed to update pending GitHub deliveries: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
494
+ return false;
495
+ }
496
+ }
497
+ removeReplyPendingInboundTask(delivery) {
498
+ try {
499
+ const tasks = this.readInboundTasks();
500
+ const matching = tasks.filter((task) => task.state === 'reply_pending' &&
501
+ task.source.chatId === delivery.chatId &&
502
+ task.source.threadId === delivery.threadId &&
503
+ task.source.messageId === delivery.sourceMessageId);
504
+ for (const task of matching) {
505
+ this.removeInboundTask(task.id);
506
+ }
507
+ }
508
+ catch (cleanupErr) {
509
+ process.stderr.write(`[Channel:${this.name}] failed to clean up inbound task: ${sanitizeLogText(cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), 200)}\n`);
510
+ }
511
+ }
512
+ pendingFinalDeliveriesPath() {
513
+ return this.channelFilePath('github-pending-deliveries.json');
514
+ }
515
+ readPendingFinalDeliveries(strict = false) {
516
+ try {
517
+ const parsed = JSON.parse(readFileSync(this.pendingFinalDeliveriesPath(), 'utf-8'));
518
+ return Array.isArray(parsed) ? parsed.filter(isPendingFinalDelivery) : [];
519
+ }
520
+ catch (err) {
521
+ if (err.code !== 'ENOENT') {
522
+ process.stderr.write(`[Channel:${this.name}] failed to read pending GitHub deliveries: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
523
+ if (strict)
524
+ throw err;
525
+ }
526
+ return [];
527
+ }
528
+ }
529
+ writePendingFinalDeliveries(records) {
530
+ const path = this.pendingFinalDeliveriesPath();
531
+ if (records.length === 0) {
532
+ try {
533
+ unlinkSync(path);
534
+ }
535
+ catch (err) {
536
+ if (err.code !== 'ENOENT')
537
+ throw err;
538
+ }
539
+ return;
540
+ }
541
+ const dir = join(getGlobalQwenDir(), 'channels', getWorkspaceScopeDirName(this.config.cwd));
542
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
543
+ chmodSync(dir, 0o700);
544
+ const tmpPath = `${path}.${process.pid}.tmp`;
545
+ writeFileSync(tmpPath, `${JSON.stringify(records)}\n`, {
546
+ encoding: 'utf-8',
547
+ mode: 0o600,
548
+ });
549
+ chmodSync(tmpPath, 0o600);
550
+ renameSync(tmpPath, path);
551
+ chmodSync(path, 0o600);
552
+ }
553
+ async createIssueComment(chatId, threadId, text, retries = 3, shouldRetry = () => true, signal) {
228
554
  if (!threadId) {
229
555
  throw new Error(`[Channel:${this.name}] createIssueComment requires a threadId`);
230
556
  }
@@ -234,12 +560,25 @@ export class GithubChannel extends PollingChannelBase {
234
560
  }
235
561
  const issueNumber = Number(match[1]);
236
562
  const [owner, repo] = chatId.split('/');
237
- const response = await this.githubApi(() => this.octokit.rest.issues.createComment({
238
- owner,
239
- repo,
240
- issue_number: issueNumber,
241
- body: text,
242
- }), `createComment(${threadId})`, retries, shouldRetry);
563
+ const response = await this.githubApi(async () => {
564
+ this.pendingFinalDeliveryRequestsActive += 1;
565
+ try {
566
+ return await this.octokit.rest.issues.createComment({
567
+ owner,
568
+ repo,
569
+ issue_number: issueNumber,
570
+ body: text,
571
+ ...(signal ? { request: { signal } } : {}),
572
+ });
573
+ }
574
+ finally {
575
+ this.pendingFinalDeliveryRequestsActive -= 1;
576
+ if (this.pendingFinalDeliveryRequestsActive === 0 &&
577
+ this.pendingFinalDeliveryRetryStopRequested) {
578
+ this.pendingFinalDeliveryRetryAbort?.abort();
579
+ }
580
+ }
581
+ }, `createComment(${threadId})`, retries, shouldRetry, signal);
243
582
  return response.data;
244
583
  }
245
584
  /**
@@ -308,6 +647,18 @@ export class GithubChannel extends PollingChannelBase {
308
647
  });
309
648
  }
310
649
  async pollOnce() {
650
+ this.inboundPersistenceBlocked = false;
651
+ if (this.inboundRecoveryPending) {
652
+ try {
653
+ await this.recoverInboundTasks();
654
+ }
655
+ catch (err) {
656
+ process.stderr.write(`[Channel:${this.name}] inbound task recovery failed, will retry next poll: ${err}\n`);
657
+ }
658
+ finally {
659
+ this.inboundRecoveryPending = false;
660
+ }
661
+ }
311
662
  this.cursor.metaFloor ??= this.cursor.lastProcessedAt;
312
663
  const since = new Date(new Date(this.cursor.lastProcessedAt).getTime() - 1000).toISOString();
313
664
  const notifications = await this.githubApi(() => this.octokit.paginate(this.octokit.rest.activity.listNotificationsForAuthenticatedUser, { since, per_page: 100 }), 'listNotifications');
@@ -320,9 +671,12 @@ export class GithubChannel extends PollingChannelBase {
320
671
  // processing in a previous poll — skip them to prevent duplicates when
321
672
  // PUT /notifications' async mark fails to mark the thread read.
322
673
  const windowSince = this.cursor.lastProcessedAt;
323
- await this.markNotificationsAsRead(maxUpdatedAt);
324
674
  if (maxUpdatedAt > this.cursor.lastProcessedAt) {
325
- this.cursor.lastProcessedAt = maxUpdatedAt;
675
+ this.pendingCursorUpdatedAt =
676
+ !this.pendingCursorUpdatedAt ||
677
+ maxUpdatedAt > this.pendingCursorUpdatedAt
678
+ ? maxUpdatedAt
679
+ : this.pendingCursorUpdatedAt;
326
680
  }
327
681
  for (const notification of notifications) {
328
682
  if (!notification.subject.url)
@@ -386,6 +740,19 @@ export class GithubChannel extends PollingChannelBase {
386
740
  continue;
387
741
  }
388
742
  }
743
+ if (this.hasRecoverableInboundTasks()) {
744
+ this.inboundRecoveryPending = true;
745
+ }
746
+ if (!this.inboundPersistenceBlocked &&
747
+ !this.hasRecoverableInboundTasks() &&
748
+ this.pendingCursorUpdatedAt) {
749
+ const committedAt = this.pendingCursorUpdatedAt;
750
+ await this.markNotificationsAsRead(committedAt);
751
+ if (committedAt > this.cursor.lastProcessedAt) {
752
+ this.cursor.lastProcessedAt = committedAt;
753
+ }
754
+ this.pendingCursorUpdatedAt = undefined;
755
+ }
389
756
  }
390
757
  async processCommentLane(ctx, onlyMentioned, directed = false) {
391
758
  const comments = await this.fetchNewComments(ctx);
@@ -415,7 +782,9 @@ export class GithubChannel extends PollingChannelBase {
415
782
  isReplyToBot: false,
416
783
  metadata: this.buildRouteMetadata(ctx),
417
784
  };
418
- if (!(await this.dispatchEnvelope(envelope, ctx.issueNumber))) {
785
+ if (!(await this.dispatchEnvelope(envelope, ctx.issueNumber, {
786
+ dispatchedComments: [key],
787
+ }))) {
419
788
  dispatched = true;
420
789
  continue;
421
790
  }
@@ -457,7 +826,9 @@ export class GithubChannel extends PollingChannelBase {
457
826
  isReplyToBot: false,
458
827
  metadata: `${this.buildMetadata(ctx.chatId, ctx.threadId, title)}\n${GITHUB_PUBLICATION_INSTRUCTIONS}\nTrigger: ${reason}.\n${buildTriggerGuidance(reason)}\n${details}`,
459
828
  };
460
- await this.dispatchEnvelope(envelope, ctx.issueNumber);
829
+ await this.dispatchEnvelope(envelope, ctx.issueNumber, {
830
+ dispatchedEvents: [trigger.key],
831
+ });
461
832
  this.recordDispatched('dispatchedEvents', trigger.key);
462
833
  }
463
834
  async processAggregateLane(ctx) {
@@ -494,7 +865,9 @@ export class GithubChannel extends PollingChannelBase {
494
865
  isReplyToBot: false,
495
866
  metadata: this.buildRouteMetadata(ctx),
496
867
  };
497
- await this.dispatchEnvelope(envelope, ctx.issueNumber);
868
+ await this.dispatchEnvelope(envelope, ctx.issueNumber, {
869
+ dispatchedComments: allComments.map((comment) => comment.node_id || String(comment.id)),
870
+ });
498
871
  }
499
872
  async findDirectTrigger(ctx, reason) {
500
873
  const [owner, repo] = ctx.chatId.split('/');
@@ -602,7 +975,9 @@ export class GithubChannel extends PollingChannelBase {
602
975
  isReplyToBot: false,
603
976
  metadata: this.buildRouteMetadata(ctx),
604
977
  };
605
- await this.dispatchEnvelope(envelope, issueNumber);
978
+ await this.dispatchEnvelope(envelope, issueNumber, {
979
+ dispatchedBodies: [bodyKey],
980
+ });
606
981
  this.recordDispatchedBody(bodyKey);
607
982
  }
608
983
  catch (err) {
@@ -621,26 +996,266 @@ export class GithubChannel extends PollingChannelBase {
621
996
  list.push(key);
622
997
  this.cursor[field] = list.slice(-MAX_DISPATCHED);
623
998
  }
624
- async dispatchEnvelope(envelope, issueNumber) {
999
+ async dispatchEnvelope(envelope, issueNumber, dedupe = {}) {
1000
+ const task = this.claimInboundTask(envelope, issueNumber, dedupe);
1001
+ return this.runInboundTask(task);
1002
+ }
1003
+ claimInboundTask(envelope, issueNumber, dedupe) {
1004
+ const existing = this.readInboundTasks().find((record) => record.source.chatId === envelope.chatId &&
1005
+ record.source.threadId === envelope.threadId &&
1006
+ record.source.messageId === envelope.messageId);
1007
+ if (existing) {
1008
+ this.applyTaskDedupe(existing);
1009
+ return existing;
1010
+ }
1011
+ const now = new Date().toISOString();
1012
+ const task = {
1013
+ version: 1,
1014
+ id: randomUUID(),
1015
+ createdAt: now,
1016
+ updatedAt: now,
1017
+ state: 'accepted',
1018
+ issueNumber,
1019
+ source: {
1020
+ chatId: envelope.chatId,
1021
+ threadId: envelope.threadId,
1022
+ messageId: envelope.messageId,
1023
+ },
1024
+ envelope,
1025
+ dedupe,
1026
+ attempts: 0,
1027
+ };
1028
+ this.updateInboundTasks((records) => [...records, task]);
1029
+ this.applyTaskDedupe(task);
1030
+ return task;
1031
+ }
1032
+ async runInboundTask(task) {
1033
+ if (!this.isRecoverableInboundTask(task))
1034
+ return true;
1035
+ const envelope = task.envelope;
1036
+ if (!envelope)
1037
+ return true;
1038
+ const attempts = (task.attempts ?? 0) + 1;
1039
+ this.activeInboundTaskIdsByMessage.set(this.inboundMessageKey(envelope.chatId, envelope.messageId), task.id);
1040
+ this.transitionInboundTask(task.id, 'running', { attempts });
1041
+ let cancelled = false;
625
1042
  try {
626
1043
  await this.handleInbound(envelope);
627
- return true;
1044
+ cancelled = this.cancelledInboundTaskIds.has(task.id);
628
1045
  }
629
1046
  catch (err) {
1047
+ const error = sanitizeLogText(err instanceof Error ? err.message : String(err), 200);
630
1048
  process.stderr.write(`[Channel:${this.name}] handleInbound failed for ${envelope.messageId}: ${err}\n`);
631
- if (!(err instanceof FinalPublicationError)) {
632
- await this.postErrorComment(envelope.chatId, issueNumber);
1049
+ if (err instanceof FinalPublicationError) {
1050
+ if (this.cancelledInboundTaskIds.has(task.id)) {
1051
+ // already persisted as 'cancelled' by onTaskLifecycle
1052
+ }
1053
+ else if (this.hasPendingFinalDeliveryForTask(task)) {
1054
+ this.transitionInboundTask(task.id, 'reply_pending', {
1055
+ envelope: undefined,
1056
+ error,
1057
+ });
1058
+ }
1059
+ else {
1060
+ this.removeInboundTask(task.id);
1061
+ }
1062
+ }
1063
+ else if (!this.cancelledInboundTaskIds.has(task.id)) {
1064
+ let posted = task.errorCommentPosted === true;
1065
+ if (!posted) {
1066
+ posted = await this.postErrorComment(envelope.chatId, task.issueNumber);
1067
+ }
1068
+ this.transitionInboundTask(task.id, 'failed', {
1069
+ error,
1070
+ attempts,
1071
+ errorCommentPosted: posted,
1072
+ });
633
1073
  }
634
1074
  return false;
635
1075
  }
1076
+ finally {
1077
+ this.activeInboundTaskIdsByMessage.delete(this.inboundMessageKey(envelope.chatId, envelope.messageId));
1078
+ this.cancelledInboundTaskIds.delete(task.id);
1079
+ }
1080
+ // A base-class cancellation resolves handleInbound normally (the cancel is
1081
+ // absorbed internally), so honour the terminal cancelled state captured
1082
+ // above instead of removing the persisted record. Bookkeeping runs outside
1083
+ // the try so a state read/write failure fails closed rather than being
1084
+ // misclassified as a task failure that recovery would re-run.
1085
+ if (cancelled)
1086
+ return true;
1087
+ if (this.hasPendingFinalDeliveryForTask(task)) {
1088
+ this.transitionInboundTask(task.id, 'reply_pending', {
1089
+ envelope: undefined,
1090
+ });
1091
+ }
1092
+ else {
1093
+ this.removeInboundTask(task.id);
1094
+ }
1095
+ return true;
1096
+ }
1097
+ async recoverInboundTasks() {
1098
+ const tasks = this.readInboundTasks().filter((task) => this.isRecoverableInboundTask(task));
1099
+ const pendingDeliveries = tasks.length
1100
+ ? this.readPendingFinalDeliveries(true)
1101
+ : [];
1102
+ const publicationAuditKeys = tasks.length
1103
+ ? this.readPublicationAuditKeys(true)
1104
+ : new Set();
1105
+ for (const task of tasks) {
1106
+ if (!task.envelope)
1107
+ continue;
1108
+ this.applyTaskDedupe(task);
1109
+ if (pendingDeliveries.some((record) => record.chatId === task.source.chatId &&
1110
+ record.threadId === task.source.threadId &&
1111
+ record.sourceMessageId === task.source.messageId)) {
1112
+ this.transitionInboundTask(task.id, 'reply_pending', {
1113
+ envelope: undefined,
1114
+ });
1115
+ continue;
1116
+ }
1117
+ if (publicationAuditKeys.has(this.inboundTaskSourceKey(task))) {
1118
+ this.removeInboundTask(task.id);
1119
+ continue;
1120
+ }
1121
+ await this.runInboundTask(task);
1122
+ }
1123
+ }
1124
+ isRecoverableInboundTask(task) {
1125
+ return (task.state === 'accepted' ||
1126
+ task.state === 'running' ||
1127
+ (task.state === 'failed' &&
1128
+ (task.attempts ?? 0) < MAX_INBOUND_TASK_ATTEMPTS));
1129
+ }
1130
+ hasRecoverableInboundTasks() {
1131
+ return this.recoverableInboundTasks > 0;
1132
+ }
1133
+ hasPendingFinalDeliveryForTask(task) {
1134
+ return this.readPendingFinalDeliveries(true).some((record) => record.chatId === task.source.chatId &&
1135
+ record.threadId === task.source.threadId &&
1136
+ record.sourceMessageId === task.source.messageId);
1137
+ }
1138
+ inboundTaskSourceKey(task) {
1139
+ return `${task.source.chatId}|${task.source.threadId ?? ''}|${task.source.messageId ?? ''}`;
1140
+ }
1141
+ readPublicationAuditKeys(strict = false) {
1142
+ try {
1143
+ const keys = new Set();
1144
+ for (const line of readFileSync(this.channelFilePath('github-audit.jsonl'), 'utf-8').split('\n')) {
1145
+ if (!line)
1146
+ continue;
1147
+ try {
1148
+ const record = JSON.parse(line);
1149
+ if (record.outcome === 'posted' ||
1150
+ record.outcome === 'suppressed' ||
1151
+ record.outcome === 'posting') {
1152
+ keys.add(`${record.repository}|${record.threadId ?? ''}|${record.sourceMessageId ?? ''}`);
1153
+ }
1154
+ }
1155
+ catch {
1156
+ continue;
1157
+ }
1158
+ }
1159
+ return keys;
1160
+ }
1161
+ catch (err) {
1162
+ if (err.code === 'ENOENT')
1163
+ return new Set();
1164
+ if (strict)
1165
+ throw err;
1166
+ return new Set();
1167
+ }
1168
+ }
1169
+ applyTaskDedupe(task) {
1170
+ for (const [field, keys] of Object.entries(task.dedupe)) {
1171
+ for (const key of keys ?? []) {
1172
+ this.recordDispatched(field, key);
1173
+ }
1174
+ }
1175
+ }
1176
+ transitionInboundTask(taskId, state, updates = {}) {
1177
+ this.updateInboundTasks((records) => records.map((record) => record.id === taskId
1178
+ ? {
1179
+ ...record,
1180
+ ...updates,
1181
+ state,
1182
+ updatedAt: new Date().toISOString(),
1183
+ }
1184
+ : record));
1185
+ }
1186
+ removeInboundTask(taskId) {
1187
+ this.updateInboundTasks((records) => records.filter((record) => record.id !== taskId));
1188
+ }
1189
+ updateInboundTasks(update) {
1190
+ try {
1191
+ const records = update(this.readInboundTasks());
1192
+ this.writeInboundTasks(records);
1193
+ this.recoverableInboundTasks = records.filter((record) => this.isRecoverableInboundTask(record)).length;
1194
+ }
1195
+ catch (err) {
1196
+ this.inboundPersistenceBlocked = true;
1197
+ throw err;
1198
+ }
1199
+ }
1200
+ inboundTasksPath() {
1201
+ return this.channelFilePath('github-inbound-tasks.json');
1202
+ }
1203
+ readInboundTasks() {
1204
+ try {
1205
+ const parsed = JSON.parse(readFileSync(this.inboundTasksPath(), 'utf-8'));
1206
+ if (!Array.isArray(parsed) || !parsed.every(isInboundTaskRecord)) {
1207
+ throw new Error('invalid GitHub inbound task state');
1208
+ }
1209
+ return parsed;
1210
+ }
1211
+ catch (err) {
1212
+ if (err.code === 'ENOENT')
1213
+ return [];
1214
+ this.inboundPersistenceBlocked = true;
1215
+ process.stderr.write(`[Channel:${this.name}] failed to read GitHub inbound tasks: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
1216
+ throw err;
1217
+ }
1218
+ }
1219
+ writeInboundTasks(records) {
1220
+ const path = this.inboundTasksPath();
1221
+ if (records.length === 0) {
1222
+ try {
1223
+ unlinkSync(path);
1224
+ }
1225
+ catch (err) {
1226
+ if (err.code !== 'ENOENT')
1227
+ throw err;
1228
+ }
1229
+ return;
1230
+ }
1231
+ const dir = dirname(path);
1232
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1233
+ chmodSync(dir, 0o700);
1234
+ const tmpPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
1235
+ writeFileSync(tmpPath, `${JSON.stringify(records)}\n`, {
1236
+ encoding: 'utf-8',
1237
+ mode: 0o600,
1238
+ });
1239
+ chmodSync(tmpPath, 0o600);
1240
+ renameSync(tmpPath, path);
1241
+ chmodSync(path, 0o600);
1242
+ }
1243
+ inboundMessageKey(chatId, messageId) {
1244
+ return `${chatId}|${messageId ?? ''}`;
1245
+ }
1246
+ onTaskLifecycle(event) {
1247
+ const taskId = this.activeInboundTaskIdsByMessage.get(this.inboundMessageKey(event.chatId, event.messageId));
1248
+ if (!taskId)
1249
+ return;
1250
+ if (event.type === 'cancelled') {
1251
+ this.cancelledInboundTaskIds.add(taskId);
1252
+ this.transitionInboundTask(taskId, 'cancelled');
1253
+ }
636
1254
  }
637
1255
  recordPublicationAudit(record) {
638
1256
  try {
639
- const encodedName = this.name
640
- .replace(/[^a-zA-Z0-9_-]/g, '_')
641
- .slice(0, 200);
642
- const dir = join(getGlobalQwenDir(), 'channels');
643
- const path = join(dir, `${encodedName}-github-audit.jsonl`);
1257
+ const dir = join(getGlobalQwenDir(), 'channels', getWorkspaceScopeDirName(this.config.cwd));
1258
+ const path = this.channelFilePath('github-audit.jsonl');
644
1259
  mkdirSync(dir, { recursive: true, mode: 0o700 });
645
1260
  chmodSync(dir, 0o700);
646
1261
  appendFileSync(path, `${JSON.stringify(record)}\n`, {
@@ -653,6 +1268,40 @@ export class GithubChannel extends PollingChannelBase {
653
1268
  process.stderr.write(`[Channel:${this.name}] publication audit write failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
654
1269
  }
655
1270
  }
1271
+ migrateLegacyPublicationState() {
1272
+ try {
1273
+ const channelsRoot = join(getGlobalQwenDir(), 'channels');
1274
+ const encodedName = this.name
1275
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
1276
+ .slice(0, 200);
1277
+ const sentinel = this.channelFilePath('github-state-migrated');
1278
+ if (existsSync(sentinel))
1279
+ return;
1280
+ mkdirSync(dirname(sentinel), { recursive: true, mode: 0o700 });
1281
+ for (const suffix of [
1282
+ 'github-pending-deliveries.json',
1283
+ 'github-audit.jsonl',
1284
+ ]) {
1285
+ const legacyPath = join(channelsRoot, `${encodedName}-${suffix}`);
1286
+ const scopedPath = this.channelFilePath(suffix);
1287
+ if (!existsSync(scopedPath) && existsSync(legacyPath)) {
1288
+ renameSync(legacyPath, scopedPath);
1289
+ }
1290
+ }
1291
+ writeFileSync(sentinel, '', { mode: 0o600 });
1292
+ }
1293
+ catch (err) {
1294
+ process.stderr.write(`[Channel:${this.name}] legacy GitHub state migration failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
1295
+ }
1296
+ }
1297
+ channelFilePath(suffix) {
1298
+ const encodedName = this.name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 200);
1299
+ const nameHash = createHash('sha256')
1300
+ .update(this.name)
1301
+ .digest('hex')
1302
+ .slice(0, 16);
1303
+ return join(getGlobalQwenDir(), 'channels', getWorkspaceScopeDirName(this.config.cwd), `${encodedName}-${nameHash}-${suffix}`);
1304
+ }
656
1305
  extractFromSubjectUrl(url) {
657
1306
  const match = url.match(/\/repos\/([^/]+\/[^/]+)\/(issues|pulls)\/(\d+)/);
658
1307
  if (!match)
@@ -671,12 +1320,16 @@ export class GithubChannel extends PollingChannelBase {
671
1320
  buildRouteMetadata(ctx) {
672
1321
  return `${this.buildMetadata(ctx.chatId, ctx.threadId, ctx.subjectTitle)}\n${GITHUB_PUBLICATION_INSTRUCTIONS}\nTrigger: ${ctx.reason}.\n${buildTriggerGuidance(ctx.reason)}`;
673
1322
  }
674
- async githubApi(fn, label, retries = 3, shouldRetry = () => true) {
1323
+ async githubApi(fn, label, retries = 3, shouldRetry = () => true, signal) {
675
1324
  for (let attempt = 1; attempt <= retries; attempt++) {
1325
+ if (signal?.aborted)
1326
+ throw new Error(`${label} aborted`);
676
1327
  try {
677
1328
  return await fn();
678
1329
  }
679
1330
  catch (err) {
1331
+ if (signal?.aborted)
1332
+ throw err;
680
1333
  if (attempt === retries || !shouldRetry(err))
681
1334
  throw err;
682
1335
  // Octokit RequestError: { status, response?: { headers } }
@@ -699,11 +1352,30 @@ export class GithubChannel extends PollingChannelBase {
699
1352
  cooldown = 1000 * 2 ** (attempt - 1);
700
1353
  }
701
1354
  process.stderr.write(`[Channel:${this.name}] ${label} failed (attempt ${attempt}/${retries}, status=${e.status}), retrying in ${cooldown}ms: ${e.message}\n`);
702
- await this.abortableSleep(cooldown);
1355
+ await this.sleepForRetry(cooldown, signal);
703
1356
  }
704
1357
  }
705
1358
  throw new Error('unreachable');
706
1359
  }
1360
+ sleepForRetry(ms, signal) {
1361
+ if (!signal)
1362
+ return this.abortableSleep(ms);
1363
+ return new Promise((resolve) => {
1364
+ if (signal.aborted) {
1365
+ resolve();
1366
+ return;
1367
+ }
1368
+ const timer = setTimeout(() => {
1369
+ signal.removeEventListener('abort', onAbort);
1370
+ resolve();
1371
+ }, ms);
1372
+ const onAbort = () => {
1373
+ clearTimeout(timer);
1374
+ resolve();
1375
+ };
1376
+ signal.addEventListener('abort', onAbort, { once: true });
1377
+ });
1378
+ }
707
1379
  async markNotificationsAsRead(lastReadAt) {
708
1380
  await this.githubApi(() => this.octokit.rest.activity.markNotificationsAsRead({
709
1381
  last_read_at: lastReadAt,
@@ -718,9 +1390,11 @@ export class GithubChannel extends PollingChannelBase {
718
1390
  issue_number: issueNumber,
719
1391
  body: '⚠️ Failed to process this request. Please re-mention the bot to retry.',
720
1392
  }), `postErrorComment(${chatId}#${issueNumber})`);
1393
+ return true;
721
1394
  }
722
1395
  catch (err) {
723
1396
  process.stderr.write(`[Channel:${this.name}] postErrorComment also failed for ${chatId}#${issueNumber}, user must re-mention manually: ${err}\n`);
1397
+ return false;
724
1398
  }
725
1399
  }
726
1400
  }