fix(github-channel): recover interrupted inbound tasks (#8306)

* fix(github-channel): recover interrupted inbound tasks

* fix(github-channel): make inbound recovery bounded

* test(github-channel): cover delivery-failure lifecycle and audit-hit recovery

Add a direct test for the onTaskLifecycle failed/delivery -> reply_pending
transition and a recovery test for the publication-audit match-and-remove
path. Restore the blank line between the constructor and createInitialCursor.

* fix(github-channel): preserve cancelled inbound tasks and fail closed on bookkeeping (#8306)

* fix(github-channel): harden inbound task lifecycle against partial persistence failures (#8306)

* fix(github-channel): close crash-window duplicates and make tests load-bearing (#8306)

* test(github-channel): add recovery test for suppressed audit outcome (#8306)

* fix(github-channel): persist errorCommentPosted after post, add review test coverage (#8306)

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
This commit is contained in:
易良 2026-08-02 15:27:44 +08:00 committed by GitHub
parent e967cc9037
commit eea0a2b3b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1448 additions and 58 deletions

View file

@ -82,7 +82,7 @@ Use `reasonFilter` to drop noisy notification classes such as `ci_activity` or `
Valid `reasonFilter` values are `mention`, `review_requested`, `assign`, `author`, `comment`, `ci_activity`, `manual`, `state_change`, `subscribed`, `team_mention`, `security_alert`, `approval_requested`, `invitation`, `member_feature_requested`, and `security_advisory_credit`.
Filtered notifications are still marked read before they are skipped. Removing the filter later will not replay notifications the channel already skipped.
Filtered notifications are marked read only after all accepted work in the poll window completes. Removing the filter later will not replay notifications the channel already skipped.
## ⚠️ Security
@ -101,12 +101,15 @@ The adapter detects mentions by scanning comment text and first-contact issue or
The adapter uses GitHub's Notifications API as a wake-up signal:
1. **Poll** `GET /notifications` for unread threads
2. **Mark read** via `markNotificationsAsRead` (best-effort cleanup, before processing)
3. **Enumerate** comments via `listComments` within a cursor-based time window
2. **Enumerate** comments via `listComments` within a cursor-based time window
3. **Persist accepted work** before dispatch, including the source envelope and deduplication keys
4. **Dispatch** by notification reason: strict mention matching, pull request review, issue triage, followed-thread comment aggregation, or per-comment fallback
5. **First-contact fallback**: a brand-new unread issue/PR body can be processed when no comment was dispatched; mention notifications still require an actual body mention
5. **Commit the poll window** only after accepted work completes: mark notifications read and advance the cursor
6. **First-contact fallback**: a brand-new unread issue/PR body can be processed when no comment was dispatched; mention notifications still require an actual body mention
The comment window is `(previousCursor, currentMaxUpdatedAt]` — comments already eligible in a previous poll cycle are excluded by the cursor, preventing duplicate replies even when the async mark-read has not taken effect. If the process crashes mid-processing, the user can re-mention the bot to retry.
The comment window is `(previousCursor, currentMaxUpdatedAt]`. Accepted, running, and failed tasks are stored under `~/.qwen/channels/<workspace-scope>/` with private file permissions. On restart, the channel recovers those tasks before polling GitHub again. Failed tasks are attempted up to three times, then become terminal; cancelled tasks are terminal and are not rerun. A task whose final reply was already posted, suppressed, or queued for definite no-write retry is not rerun.
The notification cursor does not advance while recoverable tasks remain, or when inbound task state cannot be read or written. This prevents a crash or agent failure from losing an accepted comment and preserves the deduplication keys needed to avoid a second dispatch from the notification feed.
Non-comment activity (push, label changes) bumps the notification's `updated_at` but produces zero new comments in the window, so re-fetched threads are skipped without triggering the agent.
@ -127,9 +130,10 @@ The GitHub channel always forces final-only delivery. The adapter sets `blockStr
If GitHub returns a definite no-write delivery failure, such as a rate-limit
response, the channel stores the final reply in
`~/.qwen/channels/<workspace-scope>/<channel>-<name-hash>-github-pending-deliveries.json`
with private file permissions and retries it on the next channel start.
Ambiguous delivery failures are not retried automatically because GitHub may
have created the comment.
with private file permissions and retries it on the next channel start. The
corresponding inbound task remains in `reply_pending` state until that delivery
succeeds or reaches a definite terminal failure. Ambiguous delivery failures are
not retried automatically because GitHub may have created the comment.
## Known Limitations

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ import type {
ChannelAgentBridge,
ChannelBaseOptions,
ChannelConfig,
ChannelTaskLifecycleEvent,
Envelope,
} from '@qwen-code/channel-base';
import {
@ -158,7 +159,7 @@ interface PostedGithubComment {
interface PublicationAuditRecord {
at: string;
type: 'github_publication';
outcome: 'posted' | 'suppressed' | 'failed';
outcome: 'posted' | 'suppressed' | 'failed' | 'posting';
channel: string;
triggerKind?: string;
repository: string;
@ -199,6 +200,114 @@ interface PendingFinalDelivery {
triggerKind?: string;
}
type InboundTaskState =
| 'accepted'
| 'running'
| 'reply_pending'
| 'failed'
| 'cancelled';
const MAX_INBOUND_TASK_ATTEMPTS = 3;
interface InboundTaskDedupe {
dispatchedBodies?: string[];
dispatchedComments?: string[];
dispatchedEvents?: string[];
}
interface InboundTaskRecord {
version: 1;
id: string;
createdAt: string;
updatedAt: string;
state: InboundTaskState;
issueNumber: number;
source: {
chatId: string;
threadId?: string;
messageId?: string;
};
envelope?: Envelope;
dedupe: InboundTaskDedupe;
attempts?: number;
errorCommentPosted?: boolean;
error?: string;
}
function isOptionalStringArray(value: unknown): value is string[] | undefined {
return (
value === undefined ||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
);
}
function isInboundEnvelope(value: unknown): value is Envelope | undefined {
if (value === undefined) return true;
if (value === null || typeof value !== 'object') return false;
const envelope = value as Envelope;
return (
typeof envelope.channelName === 'string' &&
typeof envelope.senderId === 'string' &&
typeof envelope.senderName === 'string' &&
typeof envelope.chatId === 'string' &&
typeof envelope.text === 'string' &&
typeof envelope.isGroup === 'boolean' &&
typeof envelope.isMentioned === 'boolean' &&
typeof envelope.isReplyToBot === 'boolean' &&
(envelope.chatName === undefined ||
typeof envelope.chatName === 'string') &&
(envelope.threadId === undefined ||
typeof envelope.threadId === 'string') &&
(envelope.messageId === undefined ||
typeof envelope.messageId === 'string') &&
(envelope.referencedText === undefined ||
typeof envelope.referencedText === 'string') &&
(envelope.imageBase64 === undefined ||
typeof envelope.imageBase64 === 'string') &&
(envelope.imageMimeType === undefined ||
typeof envelope.imageMimeType === 'string') &&
(envelope.attachments === undefined ||
Array.isArray(envelope.attachments)) &&
(envelope.metadata === undefined ||
typeof envelope.metadata === 'string') &&
(envelope.alreadyPrefixed === undefined ||
envelope.alreadyPrefixed === true)
);
}
function isInboundTaskRecord(value: unknown): value is InboundTaskRecord {
if (value === null || typeof value !== 'object') return false;
const record = value as InboundTaskRecord;
return (
record.version === 1 &&
typeof record.id === 'string' &&
typeof record.createdAt === 'string' &&
typeof record.updatedAt === 'string' &&
['accepted', 'running', 'reply_pending', 'failed', 'cancelled'].includes(
record.state,
) &&
Number.isSafeInteger(record.issueNumber) &&
record.source !== null &&
typeof record.source === 'object' &&
typeof record.source.chatId === 'string' &&
(record.source.threadId === undefined ||
typeof record.source.threadId === 'string') &&
(record.source.messageId === undefined ||
typeof record.source.messageId === 'string') &&
isInboundEnvelope(record.envelope) &&
record.dedupe !== null &&
typeof record.dedupe === 'object' &&
isOptionalStringArray(record.dedupe.dispatchedBodies) &&
isOptionalStringArray(record.dedupe.dispatchedComments) &&
isOptionalStringArray(record.dedupe.dispatchedEvents) &&
(record.attempts === undefined ||
(Number.isSafeInteger(record.attempts) && record.attempts >= 0)) &&
(record.errorCommentPosted === undefined ||
typeof record.errorCommentPosted === 'boolean') &&
(record.error === undefined || typeof record.error === 'string')
);
}
class FinalPublicationError extends Error {}
const NO_REPLY_SENTINEL = '<no-reply/>';
@ -268,6 +377,12 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
private pendingFinalDeliveryRequestsActive = 0;
private pendingFinalDeliveryRetryStopRequested = false;
private reasonFilter: Set<string> | null = null;
private inboundRecoveryPending = true;
private recoverableInboundTasks = 0;
private activeInboundTaskIdsByMessage = new Map<string, string>();
private cancelledInboundTaskIds = new Set<string>();
private pendingCursorUpdatedAt: string | undefined;
private inboundPersistenceBlocked = false;
constructor(
name: string,
@ -358,6 +473,15 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
}
this.gate.replaceAllowedUsers(allowed);
this.migrateLegacyPublicationState();
this.inboundPersistenceBlocked = false;
this.inboundRecoveryPending = true;
try {
this.recoverableInboundTasks = this.readInboundTasks().filter((record) =>
this.isRecoverableInboundTask(record),
).length;
} catch {
this.recoverableInboundTasks = 0;
}
this.pendingFinalDeliveryRetryStopRequested = false;
this.startPollLoop();
if (this.pendingFinalDeliveryRetry) {
@ -457,6 +581,12 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
);
}
this.recordPublicationAudit({
...auditBase,
at: new Date().toISOString(),
type: 'github_publication',
outcome: 'posting',
});
try {
const comment = await this.createIssueComment(
chatId,
@ -486,12 +616,24 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
),
});
if (threadId && isDefiniteNoWriteGithubError(err)) {
this.enqueuePendingFinalDelivery({
...auditBase,
chatId,
threadId,
fullText,
});
try {
this.enqueuePendingFinalDelivery({
...auditBase,
chatId,
threadId,
fullText,
});
} catch (persistError) {
throw new Error(
`[Channel:${this.name}] failed to persist pending GitHub delivery: ${sanitizeLogText(
persistError instanceof Error
? persistError.message
: String(persistError),
200,
)}`,
{ cause: persistError },
);
}
}
throw new FinalPublicationError(
err instanceof Error ? err.message : String(err),
@ -552,19 +694,10 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
actor: input.actor,
triggerKind: input.triggerKind,
};
try {
const pending = this.readPendingFinalDeliveries().filter(
(item) => item.id !== record.id,
);
this.writePendingFinalDeliveries([...pending, record]);
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] failed to persist pending GitHub delivery: ${sanitizeLogText(
err instanceof Error ? err.message : String(err),
200,
)}\n`,
);
}
const pending = this.readPendingFinalDeliveries(true).filter(
(item) => item.id !== record.id,
);
this.writePendingFinalDeliveries([...pending, record]);
}
private async retryPendingFinalDeliveries(
@ -591,9 +724,13 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
80,
)}\n`,
);
this.updatePendingFinalDeliveries((current) =>
current.filter((item) => item.id !== record.id),
);
if (
this.updatePendingFinalDeliveries((current) =>
current.filter((item) => item.id !== record.id),
)
) {
this.removeReplyPendingInboundTask(record);
}
continue;
}
try {
@ -624,6 +761,7 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
) {
continue;
}
this.removeReplyPendingInboundTask(record);
} catch (err) {
if (signal?.aborted) return;
if (isDefiniteNoWriteGithubError(err)) {
@ -647,6 +785,7 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
) {
continue;
}
this.removeReplyPendingInboundTask(record);
}
}
}
@ -689,6 +828,29 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
}
}
private removeReplyPendingInboundTask(delivery: PendingFinalDelivery): void {
try {
const tasks = this.readInboundTasks();
const matching = tasks.filter(
(task) =>
task.state === 'reply_pending' &&
task.source.chatId === delivery.chatId &&
task.source.threadId === delivery.threadId &&
task.source.messageId === delivery.sourceMessageId,
);
for (const task of matching) {
this.removeInboundTask(task.id);
}
} catch (cleanupErr) {
process.stderr.write(
`[Channel:${this.name}] failed to clean up inbound task: ${sanitizeLogText(
cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
200,
)}\n`,
);
}
}
private pendingFinalDeliveriesPath(): string {
return this.channelFilePath('github-pending-deliveries.json');
}
@ -874,6 +1036,19 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
}
protected async pollOnce(): Promise<void> {
this.inboundPersistenceBlocked = false;
if (this.inboundRecoveryPending) {
try {
await this.recoverInboundTasks();
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] inbound task recovery failed, will retry next poll: ${err}\n`,
);
} finally {
this.inboundRecoveryPending = false;
}
}
this.cursor.metaFloor ??= this.cursor.lastProcessedAt;
const since = new Date(
new Date(this.cursor.lastProcessedAt).getTime() - 1000,
@ -901,12 +1076,13 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
// PUT /notifications' async mark fails to mark the thread read.
const windowSince = this.cursor.lastProcessedAt;
await this.markNotificationsAsRead(maxUpdatedAt);
if (maxUpdatedAt > this.cursor.lastProcessedAt) {
this.cursor.lastProcessedAt = maxUpdatedAt;
this.pendingCursorUpdatedAt =
!this.pendingCursorUpdatedAt ||
maxUpdatedAt > this.pendingCursorUpdatedAt
? maxUpdatedAt
: this.pendingCursorUpdatedAt;
}
for (const notification of notifications) {
if (!notification.subject.url) continue;
const extracted = this.extractFromSubjectUrl(notification.subject.url);
@ -972,6 +1148,21 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
continue;
}
}
if (this.hasRecoverableInboundTasks()) {
this.inboundRecoveryPending = true;
}
if (
!this.inboundPersistenceBlocked &&
!this.hasRecoverableInboundTasks() &&
this.pendingCursorUpdatedAt
) {
const committedAt = this.pendingCursorUpdatedAt;
await this.markNotificationsAsRead(committedAt);
if (committedAt > this.cursor.lastProcessedAt) {
this.cursor.lastProcessedAt = committedAt;
}
this.pendingCursorUpdatedAt = undefined;
}
}
private async processCommentLane(
@ -1008,7 +1199,11 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
metadata: this.buildRouteMetadata(ctx),
};
if (!(await this.dispatchEnvelope(envelope, ctx.issueNumber))) {
if (
!(await this.dispatchEnvelope(envelope, ctx.issueNumber, {
dispatchedComments: [key],
}))
) {
dispatched = true;
continue;
}
@ -1057,7 +1252,9 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
isReplyToBot: false,
metadata: `${this.buildMetadata(ctx.chatId, ctx.threadId, title)}\n${GITHUB_PUBLICATION_INSTRUCTIONS}\nTrigger: ${reason}.\n${buildTriggerGuidance(reason)}\n${details}`,
};
await this.dispatchEnvelope(envelope, ctx.issueNumber);
await this.dispatchEnvelope(envelope, ctx.issueNumber, {
dispatchedEvents: [trigger.key],
});
this.recordDispatched('dispatchedEvents', trigger.key);
}
@ -1102,7 +1299,11 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
metadata: this.buildRouteMetadata(ctx),
};
await this.dispatchEnvelope(envelope, ctx.issueNumber);
await this.dispatchEnvelope(envelope, ctx.issueNumber, {
dispatchedComments: allComments.map(
(comment) => comment.node_id || String(comment.id),
),
});
}
private async findDirectTrigger(
@ -1252,7 +1453,9 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
metadata: this.buildRouteMetadata(ctx),
};
await this.dispatchEnvelope(envelope, issueNumber);
await this.dispatchEnvelope(envelope, issueNumber, {
dispatchedBodies: [bodyKey],
});
this.recordDispatchedBody(bodyKey);
} catch (err) {
process.stderr.write(
@ -1281,18 +1484,325 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
private async dispatchEnvelope(
envelope: Envelope,
issueNumber: number,
dedupe: InboundTaskDedupe = {},
): Promise<boolean> {
const task = this.claimInboundTask(envelope, issueNumber, dedupe);
return this.runInboundTask(task);
}
private claimInboundTask(
envelope: Envelope,
issueNumber: number,
dedupe: InboundTaskDedupe,
): InboundTaskRecord {
const existing = this.readInboundTasks().find(
(record) =>
record.source.chatId === envelope.chatId &&
record.source.threadId === envelope.threadId &&
record.source.messageId === envelope.messageId,
);
if (existing) {
this.applyTaskDedupe(existing);
return existing;
}
const now = new Date().toISOString();
const task: InboundTaskRecord = {
version: 1,
id: randomUUID(),
createdAt: now,
updatedAt: now,
state: 'accepted',
issueNumber,
source: {
chatId: envelope.chatId,
threadId: envelope.threadId,
messageId: envelope.messageId,
},
envelope,
dedupe,
attempts: 0,
};
this.updateInboundTasks((records) => [...records, task]);
this.applyTaskDedupe(task);
return task;
}
private async runInboundTask(task: InboundTaskRecord): Promise<boolean> {
if (!this.isRecoverableInboundTask(task)) return true;
const envelope = task.envelope;
if (!envelope) return true;
const attempts = (task.attempts ?? 0) + 1;
this.activeInboundTaskIdsByMessage.set(
this.inboundMessageKey(envelope.chatId, envelope.messageId),
task.id,
);
this.transitionInboundTask(task.id, 'running', { attempts });
let cancelled = false;
try {
await this.handleInbound(envelope);
return true;
cancelled = this.cancelledInboundTaskIds.has(task.id);
} catch (err) {
const error = sanitizeLogText(
err instanceof Error ? err.message : String(err),
200,
);
process.stderr.write(
`[Channel:${this.name}] handleInbound failed for ${envelope.messageId}: ${err}\n`,
);
if (!(err instanceof FinalPublicationError)) {
await this.postErrorComment(envelope.chatId, issueNumber);
if (err instanceof FinalPublicationError) {
if (this.cancelledInboundTaskIds.has(task.id)) {
// already persisted as 'cancelled' by onTaskLifecycle
} else if (this.hasPendingFinalDeliveryForTask(task)) {
this.transitionInboundTask(task.id, 'reply_pending', {
envelope: undefined,
error,
});
} else {
this.removeInboundTask(task.id);
}
} else if (!this.cancelledInboundTaskIds.has(task.id)) {
let posted = task.errorCommentPosted === true;
if (!posted) {
posted = await this.postErrorComment(
envelope.chatId,
task.issueNumber,
);
}
this.transitionInboundTask(task.id, 'failed', {
error,
attempts,
errorCommentPosted: posted,
});
}
return false;
} finally {
this.activeInboundTaskIdsByMessage.delete(
this.inboundMessageKey(envelope.chatId, envelope.messageId),
);
this.cancelledInboundTaskIds.delete(task.id);
}
// A base-class cancellation resolves handleInbound normally (the cancel is
// absorbed internally), so honour the terminal cancelled state captured
// above instead of removing the persisted record. Bookkeeping runs outside
// the try so a state read/write failure fails closed rather than being
// misclassified as a task failure that recovery would re-run.
if (cancelled) return true;
if (this.hasPendingFinalDeliveryForTask(task)) {
this.transitionInboundTask(task.id, 'reply_pending', {
envelope: undefined,
});
} else {
this.removeInboundTask(task.id);
}
return true;
}
private async recoverInboundTasks(): Promise<void> {
const tasks = this.readInboundTasks().filter((task) =>
this.isRecoverableInboundTask(task),
);
const pendingDeliveries = tasks.length
? this.readPendingFinalDeliveries(true)
: [];
const publicationAuditKeys = tasks.length
? this.readPublicationAuditKeys(true)
: new Set<string>();
for (const task of tasks) {
if (!task.envelope) continue;
this.applyTaskDedupe(task);
if (
pendingDeliveries.some(
(record) =>
record.chatId === task.source.chatId &&
record.threadId === task.source.threadId &&
record.sourceMessageId === task.source.messageId,
)
) {
this.transitionInboundTask(task.id, 'reply_pending', {
envelope: undefined,
});
continue;
}
if (publicationAuditKeys.has(this.inboundTaskSourceKey(task))) {
this.removeInboundTask(task.id);
continue;
}
await this.runInboundTask(task);
}
}
private isRecoverableInboundTask(task: InboundTaskRecord): boolean {
return (
task.state === 'accepted' ||
task.state === 'running' ||
(task.state === 'failed' &&
(task.attempts ?? 0) < MAX_INBOUND_TASK_ATTEMPTS)
);
}
private hasRecoverableInboundTasks(): boolean {
return this.recoverableInboundTasks > 0;
}
private hasPendingFinalDeliveryForTask(task: InboundTaskRecord): boolean {
return this.readPendingFinalDeliveries(true).some(
(record) =>
record.chatId === task.source.chatId &&
record.threadId === task.source.threadId &&
record.sourceMessageId === task.source.messageId,
);
}
private inboundTaskSourceKey(task: InboundTaskRecord): string {
return `${task.source.chatId}|${task.source.threadId ?? ''}|${task.source.messageId ?? ''}`;
}
private readPublicationAuditKeys(strict = false): Set<string> {
try {
const keys = new Set<string>();
for (const line of readFileSync(
this.channelFilePath('github-audit.jsonl'),
'utf-8',
).split('\n')) {
if (!line) continue;
try {
const record = JSON.parse(line) as PublicationAuditRecord;
if (
record.outcome === 'posted' ||
record.outcome === 'suppressed' ||
record.outcome === 'posting'
) {
keys.add(
`${record.repository}|${record.threadId ?? ''}|${record.sourceMessageId ?? ''}`,
);
}
} catch {
continue;
}
}
return keys;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return new Set();
if (strict) throw err;
return new Set();
}
}
private applyTaskDedupe(task: InboundTaskRecord): void {
for (const [field, keys] of Object.entries(task.dedupe) as Array<
[keyof InboundTaskDedupe, string[] | undefined]
>) {
for (const key of keys ?? []) {
this.recordDispatched(field, key);
}
}
}
private transitionInboundTask(
taskId: string,
state: InboundTaskState,
updates: Partial<
Pick<
InboundTaskRecord,
'envelope' | 'error' | 'attempts' | 'errorCommentPosted'
>
> = {},
): void {
this.updateInboundTasks((records) =>
records.map((record) =>
record.id === taskId
? {
...record,
...updates,
state,
updatedAt: new Date().toISOString(),
}
: record,
),
);
}
private removeInboundTask(taskId: string): void {
this.updateInboundTasks((records) =>
records.filter((record) => record.id !== taskId),
);
}
private updateInboundTasks(
update: (records: InboundTaskRecord[]) => InboundTaskRecord[],
): void {
try {
const records = update(this.readInboundTasks());
this.writeInboundTasks(records);
this.recoverableInboundTasks = records.filter((record) =>
this.isRecoverableInboundTask(record),
).length;
} catch (err) {
this.inboundPersistenceBlocked = true;
throw err;
}
}
private inboundTasksPath(): string {
return this.channelFilePath('github-inbound-tasks.json');
}
private readInboundTasks(): InboundTaskRecord[] {
try {
const parsed = JSON.parse(readFileSync(this.inboundTasksPath(), 'utf-8'));
if (!Array.isArray(parsed) || !parsed.every(isInboundTaskRecord)) {
throw new Error('invalid GitHub inbound task state');
}
return parsed;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
this.inboundPersistenceBlocked = true;
process.stderr.write(
`[Channel:${this.name}] failed to read GitHub inbound tasks: ${sanitizeLogText(
err instanceof Error ? err.message : String(err),
200,
)}\n`,
);
throw err;
}
}
private writeInboundTasks(records: InboundTaskRecord[]): void {
const path = this.inboundTasksPath();
if (records.length === 0) {
try {
unlinkSync(path);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
return;
}
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
const tmpPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(tmpPath, `${JSON.stringify(records)}\n`, {
encoding: 'utf-8',
mode: 0o600,
});
chmodSync(tmpPath, 0o600);
renameSync(tmpPath, path);
chmodSync(path, 0o600);
}
private inboundMessageKey(chatId: string, messageId?: string): string {
return `${chatId}|${messageId ?? ''}`;
}
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
const taskId = this.activeInboundTaskIdsByMessage.get(
this.inboundMessageKey(event.chatId, event.messageId),
);
if (!taskId) return;
if (event.type === 'cancelled') {
this.cancelledInboundTaskIds.add(taskId);
this.transitionInboundTask(taskId, 'cancelled');
}
}
@ -1475,7 +1985,7 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
private async postErrorComment(
chatId: string,
issueNumber: number,
): Promise<void> {
): Promise<boolean> {
try {
await this.githubApi(
() =>
@ -1487,10 +1997,12 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
}),
`postErrorComment(${chatId}#${issueNumber})`,
);
return true;
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] postErrorComment also failed for ${chatId}#${issueNumber}, user must re-mention manually: ${err}\n`,
);
return false;
}
}
}