@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.
- package/dist/GithubAdapter.d.ts +42 -2
- package/dist/GithubAdapter.d.ts.map +1 -1
- package/dist/GithubAdapter.js +713 -39
- package/dist/GithubAdapter.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/GithubAdapter.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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(() =>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
632
|
-
|
|
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
|
|
640
|
-
|
|
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.
|
|
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
|
}
|