Files
fusion/packages/dashboard/src/github-tracking-state.ts
gsxdsm 006cc40454 FN-8685: add durable cross-process task deletion consumers
Deliver durable, replay-safe cross-process task deletion observation.

- Add PostgreSQL lifecycle consumer cursors, leases, acknowledgements, retention, and recovery.
- Start named consumers in dashboard, serve, and engine runtime paths.
- Preserve delete integration metadata while suppressing replayed GitHub and GitLab side effects.
- Cover outbox identity, observed delivery, fencing, and reconciliation behavior.

Files changed:
 ...fn-8685-cross-process-task-deleted-observers.md |   7 +
 .../fn-8685-task-deleted-outbox-consumers.md       |   7 +
 docs/architecture.md                               |   8 +-
 ...tgres-cross-process-task-deleted-observation.md |   8 +-
 docs/storage.md                                    |  10 +-
 packages/cli/src/commands/dashboard.ts             |   9 +-
 packages/cli/src/commands/serve.ts                 |   9 +-
 packages/cli/src/project-context.ts                |   9 +-
 .../task-deleted-outbox-consumer.pg.test.ts        | 157 ++++++++
 ...-deleted-observed-dispatch-side-effects.test.ts |  36 ++
 .../task-lifecycle-consumer-identity.test.ts       |  22 ++
 packages/core/src/index.ts                         |  11 +
 .../0041_fn_8685_task_lifecycle_consumers.sql      |  88 +++++
 packages/core/src/postgres/schema-applier.ts       |  16 +-
 packages/core/src/postgres/schema/project.ts       |  45 +++
 packages/core/src/postgres/startup-factory.ts      |   4 +
 packages/core/src/store.ts                         |  54 ++-
 .../__tests__/lifecycle-outbox-writer.test.ts      |   4 +-
 .../core/src/task-store/archive-lifecycle-2.ts     |   1 +
 packages/core/src/task-store/lifecycle-ops.ts      |  13 +-
 packages/core/src/task-store/lifecycle-outbox.ts   |   2 +
 packages/core/src/task-store/project-store-ops.ts  |   4 +-
 .../src/task-store/task-deleted-outbox-consumer.ts | 333 +++++++++++++++++
 .../task-store/task-lifecycle-consumer-identity.ts |  32 ++
 .../task-store/task-lifecycle-consumer-registry.ts | 396 +++++++++++++++++++++
 .../task-store/task-lifecycle-event-retention.ts   | 104 ++++++
 packages/core/src/task-store/task-mutation-ops.ts  |   1 +
 packages/dashboard/src/github-tracking-state.ts    |  12 +-
 packages/dashboard/src/gitlab-delete-close.ts      |   3 +
 packages/dashboard/src/gitlab-split-close.ts       |   7 +-
 packages/dashboard/src/project-store-resolver.ts   |   9 +-
 packages/engine/src/project-manager.ts             |   4 +-
 packages/engine/src/project-runtime.ts             |   2 +-
 packages/engine/src/runtimes/in-process-runtime.ts |  17 +-
 packages/engine/src/self-healing.ts                |  27 ++
 35 files changed, 1439 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-8685
Fusion-Task-Lineage: 63eca9ac-d2af-44b0-ba79-388a950148d3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 06:09:04 -07:00

647 lines
27 KiB
TypeScript

import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-github-tracking-state");
import type { GithubIssueAction, GlobalSettings, ProjectSettings, Task, TaskDeleteClosureContext, TaskStore } from "@fusion/core";
import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
/*
FNXC:WorkflowResolvedColumns 2026-07-31-13:40 (fleet — inline fallback arms):
DELIBERATE-LITERAL — the no-resolution fallback for the already-converted guards below.
Named sets rather than an inline `=== "done"` arm. Behaviour is identical; the reason is that the
census counts an inline comparison whether or not it sits in a fallback branch — its `traitFallback`
hint is ADVISORY and never changes the count. So a correctly-converted guard with an inline legacy
arm stays on the backlog permanently, and the number stops distinguishing real debt from documented
degraded answers. Same shape as `LEGACY_PLANNER_LANES` and `LEGACY_TERMINAL_COLUMNS`.
*/
const LEGACY_COMPLETE_LANES: ReadonlySet<string> = new Set(["done"]);
const LEGACY_ARCHIVE_LANES: ReadonlySet<string> = new Set(["archived"]);
const TRANSIENT_RETRY_DELAY_MS = 25;
interface TaskMovedEvent {
task: {
id: string;
githubTracking?: {
enabled?: boolean;
issue?: {
owner?: string;
repo?: string;
number?: number;
url?: string;
htmlUrl?: string;
createdAt?: string;
};
};
};
// #1403: the store's `task:moved` event carries `ColumnId` (custom column ids
// admitted). U12 re-keys the decision on the complete/archived traits, so a
// workflow-defined terminal column maps to GitHub state like `done` does.
from: string;
to: string;
}
/*
FNXC:WorkflowColumns 2026-07-19-2b:50 (U12 / R2):
GitHub open/closed state keys on the `complete` and `archived` TRAITS, not the literal ids `done`
and `archived`. A user-authored workflow whose terminal column is called something else never
closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`.
`classify` is injected rather than resolved here so this stays a pure decision function (the
caller owns IR resolution). Its default reproduces the legacy literal mapping exactly, so every
existing caller and the default workflow are byte-identical.
*/
export interface ColumnLifecycleClass {
complete: boolean;
archived: boolean;
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:15 DELIBERATE-LITERAL:
The named legacy default of the injected-classifier seam, and the only place these two ids remain in this
file. It is the answer when no workflow can be resolved — the documented degraded mode — not an
unconverted guard: `decideIssueAction`'s `classify` parameter defaults to it so a caller without an IR
keeps today's mapping exactly.
Marked deliberate only now that the production caller actually passes a RESOLVED classifier. Before that
this default was the live path on every move, and exempting it would have hidden the real defect behind a
marker — which is why the wiring change and this marker are in the same commit.
*/
export const legacyColumnLifecycleClass = (columnId: string): ColumnLifecycleClass => ({
complete: columnId === "done",
archived: columnId === "archived",
});
export function decideIssueAction(
from: string,
to: string,
classify: (columnId: string) => ColumnLifecycleClass = legacyColumnLifecycleClass,
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
const fromClass = classify(from);
const toClass = classify(to);
// Un-archiving back into the completed column re-opens the issue.
if (fromClass.archived && toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
if (toClass.complete && !fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
if (toClass.archived) {
if (fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
if (!fromClass.archived) {
return { action: "close", stateReason: "not_planned" };
}
return null;
}
// Leaving the completed column re-opens the issue.
if (fromClass.complete && !toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
return null;
}
export function isTransientGitHubError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
const status = (error as Error & { status?: number; statusCode?: number }).status
?? (error as Error & { status?: number; statusCode?: number }).statusCode;
return (typeof status === "number" && status >= 500)
|| message.includes("econn")
|| message.includes("timed out")
|| message.includes("socket hang up");
}
export async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
type GitHubIssueActionEvent = {
taskId: string;
action: "close" | "reopen" | "delete" | "leave";
owner: string;
repo: string;
number: number;
outcome: "success" | "failed" | "skipped";
error?: string;
};
type TaskDeletedMeta = {
githubIssueAction?: GithubIssueAction;
closureContext?: TaskDeleteClosureContext;
observed?: boolean;
};
type SplitCommentLog = (message: string, details: string) => Promise<void>;
/*
FNXC:GitHubSourceIssueSplitClose 2026-08-01-09:24:
A split-close posts exactly one explanation immediately before exactly one close for each issue.
Both source and tracking paths call this single helper; a comment failure is logged but deliberately
never blocks the close, preserving the existing lifecycle outcome even when GitHub commenting fails.
A close retry must never replay this helper because GitHub can accept a comment before returning a
transient failure, so the comment is deliberately attempted once.
*/
async function postSplitCommentBeforeClose(
client: GitHubClient,
owner: string,
repo: string,
number: number,
taskId: string,
closureContext: TaskDeleteClosureContext | undefined,
resolvedAction: GithubIssueAction | "close",
alreadyClosed: boolean,
log: SplitCommentLog,
): Promise<void> {
if (closureContext?.kind !== "split-into-subtasks" || resolvedAction !== "close" || alreadyClosed
|| !owner || !repo || !Number.isInteger(number) || number <= 0) return;
const body = `This issue was imported as Fusion task ${taskId}, which has been broken down into subtasks: ${closureContext.childTaskIds.join(", ")}. Closing this issue; work continues in those tasks.`;
try {
await client.commentOnIssue(owner, repo, number, body);
await log("Posted GitHub source issue split comment", `${owner}/${repo}#${number}`);
} catch (error) {
await log("Failed to post GitHub source issue split comment", error instanceof Error ? error.message : String(error));
}
}
function sourceMatchesTrackingIssue(task: Task, owner: string, repo: string, number: number): boolean {
const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider !== "github" || !Number.isInteger(sourceIssue.issueNumber)) return false;
const [sourceOwner, sourceRepo, extra] = sourceIssue.repository.split("/");
return !extra && sourceOwner?.toLowerCase() === owner.toLowerCase()
&& sourceRepo?.toLowerCase() === repo.toLowerCase()
&& sourceIssue.issueNumber === number;
}
export class GitHubTrackingStateService {
private readonly defaultStore: TaskStore;
private readonly listeners = new Map<TaskStore, {
onTaskMoved: (event: TaskMovedEvent) => void;
onTaskDeleted: (task: Task, meta?: TaskDeletedMeta) => void;
}>();
private started = false;
constructor(store: TaskStore) {
this.defaultStore = store;
}
start(): void {
if (this.started) return;
this.started = true;
this.attach(this.defaultStore);
}
stop(): void {
if (!this.started) return;
this.started = false;
for (const store of this.listeners.keys()) {
this.detach(store);
}
}
attach(store: TaskStore): void {
if (this.listeners.has(store)) {
return;
}
const onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(store, event);
};
const onTaskDeleted = (task: Task, meta?: TaskDeletedMeta): void => {
void this.handleTaskDeleted(store, task, meta);
};
this.listeners.set(store, { onTaskMoved, onTaskDeleted });
if (this.started) {
store.on("task:moved", onTaskMoved);
store.on("task:deleted", onTaskDeleted);
}
}
detach(store: TaskStore): void {
const handlers = this.listeners.get(store);
if (!handlers) {
return;
}
store.off("task:moved", handlers.onTaskMoved);
store.off("task:deleted", handlers.onTaskDeleted);
this.listeners.delete(store);
}
private async handleTaskMoved(store: TaskStore, event: TaskMovedEvent): Promise<void> {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:15 (fleet phase — THE SEAM WAS NEVER WIRED):
`decideIssueAction` has taken an injectable `classify` since U12/R2, and the header above states the
defect it fixed: "a user-authored workflow whose terminal column is called something else never closed
its linked GitHub issue". But this — the ONLY production caller — passed no classifier, so every real
move fell through to `legacyColumnLifecycleClass` and the described bug was still live. The seam was
reachable from tests only.
That is the same shape as this branch's earlier finding on the tracking-comment guard: a conversion
that reads as done, with the resolved path unreachable in production. The lesson is that adding the
seam and wiring it are two changes, and only the second one fixes anything.
ORDER MATTERS, and it is inverted from the original. `decideIssueAction` ran FIRST here, before the
tracking-enabled check, because comparing two strings is free. Resolving a workflow is not, so the
cheap property read now short-circuits first and only tracked tasks resolve — the same ordering
`github-tracking-comments.ts` and its GitLab twin settled on. Behaviour is unchanged for untracked
tasks: they returned without acting before and still do.
*/
if (event.task.githubTracking?.enabled !== true) {
return;
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-14:20 (PR #2754 review — greptile):
EVERY TERMINAL LANE, NOT THE FIRST ONE. `LifecycleColumns` names one column per role by design
(#2721 pinned that), so a workflow declaring `complete` or `archived` on two columns had the second
invisible here: moving a card there left the linked GitHub issue OPEN, and moving it back out never
reopened one.
Core's `resolveTerminalColumns` does not help — it is the same singular pair, one `complete` and one
`archived`. The flag sets are the membership answer.
RESOLUTION FAILURE vs A RESOLVED ABSENCE, the distinction this program keeps paying for (#2731,
#2733, #2734): `ir === undefined` means the workflow could not be READ, and the legacy ids are the
only answer available. A resolved IR that declares no complete lane is an ANSWER — moving a card
somewhere is not "completing" it on a board with no completion lane — so the empty set is used as-is
rather than falling back to `done`.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-10:45 (batch-core — the THIRD state):
The note above draws the right line between "could not read" and "read, and there is no complete
lane". There is a third case that looks exactly like the second and means the opposite, and this
classifier was silently on the wrong side of it.
`synthesizeDefaultColumns` upgrades a v1 graph by emitting every default column with `traits: []`.
Such a board resolves cleanly, so `ir !== undefined`, and every flag set comes back EMPTY — while
its `done` column plainly exists and holds finished cards. Treating that as "this board does not
complete anything" made `decideIssueAction` return null for every transition, so on a v1-upgraded
board GitHub tracking NEVER closed a source issue. And because the source-issue commenter defers to
this service whenever tracking targets the same issue, neither of them posted: the completion
comment disappeared entirely, with nothing logged as an error.
`declaresAnyLifecycleTrait` separates the two. A workflow that expresses no trait on ANY column has
not made a statement about its lifecycle and keeps the legacy vocabulary; a v2 board that declares
traits elsewhere but no complete lane still gets the empty set used as-is, which is the behaviour
the note above argues for and which remains correct.
*/
const ir = await resolveWorkflowIrForTask(store, event.task.id).catch(() => undefined);
const traitsExpressed = ir !== undefined && declaresAnyLifecycleTrait(ir);
const completeLanes = ir === undefined || !traitsExpressed ? undefined : columnsWithFlag(ir, "complete");
const archivedLanes = ir === undefined || !traitsExpressed ? undefined : columnsWithFlag(ir, "archived");
const decision = decideIssueAction(event.from, event.to, (columnId) => ({
complete: completeLanes === undefined ? LEGACY_COMPLETE_LANES.has(columnId) : completeLanes.includes(columnId),
archived: archivedLanes === undefined ? LEGACY_ARCHIVE_LANES.has(columnId) : archivedLanes.includes(columnId),
}));
if (!decision) {
return;
}
const issue = event.task.githubTracking?.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
return;
}
try {
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
await this.safeLogDeletedTaskEntry(store, event.task.id, "Skipped GitHub tracking issue state update", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (decision.action === "close") {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, event.task.id, "Linked GitHub tracking issue already closed", `${owner}/${repo}#${number}`);
return;
}
}
const updateIssueState = async () => {
await client.setIssueState(
owner,
repo,
number,
decision.action === "close" ? "closed" : "open",
decision.stateReason,
);
};
try {
await updateIssueState();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await updateIssueState();
}
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
decision.action === "close"
? "Closed linked GitHub tracking issue"
: "Reopened linked GitHub tracking issue",
`${owner}/${repo}#${number}`,
);
} catch (err) {
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
decision.action === "close"
? "Failed to close GitHub tracking issue"
: "Failed to reopen GitHub tracking issue",
err instanceof Error ? err.message : String(err),
);
}
}
private emitGitHubIssueAction(store: TaskStore, event: GitHubIssueActionEvent): void {
(store as unknown as { emit: (eventName: string, payload: GitHubIssueActionEvent) => void }).emit("github-issue:action", event);
}
private async safeLogDeletedTaskEntry(store: TaskStore, taskId: string, message: string, details: string): Promise<void> {
try {
await store.logEntry(taskId, message, details);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes(`Task ${taskId} not found`)) {
severityAuditLog.warn(`[github-tracking-state] Unable to write log entry for deleted task ${taskId}: ${message}`);
return;
}
throw error;
}
}
private async handleSourceIssueDelete(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise<void> {
const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider !== "github") {
return;
}
const [owner, repo, extra] = sourceIssue.repository.split("/");
if (!owner || !repo || extra) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue repository: ${sourceIssue.repository}`,
);
return;
}
const number = sourceIssue.issueNumber;
if (!Number.isInteger(number) || number <= 0) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue number: ${String(number)}`,
);
return;
}
const githubIssueAction = meta?.githubIssueAction ?? "auto";
// Source-imported issues represent real incoming work; if no explicit action is provided,
// deleting the task defaults to closing the source issue.
const resolvedAction = githubIssueAction === "auto" ? "close" : githubIssueAction;
if (resolvedAction === "leave") {
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked source GitHub issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
this.emitGitHubIssueAction(store, {
taskId: task.id,
action: resolvedAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (resolvedAction === "delete") {
try {
const deleteIssue = async () => {
await client.deleteIssue(owner, repo, number);
};
try {
await deleteIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await deleteIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Deleted linked source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to delete linked source GitHub issue", message);
}
return;
}
try {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked source GitHub issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
await postSplitCommentBeforeClose(
client, owner, repo, number, task.id, meta?.closureContext, resolvedAction, false,
(message, details) => this.safeLogDeletedTaskEntry(store, task.id, message, details),
);
const closeIssue = async () => {
// Source-imported issues map to completed work, so closure reason is "completed".
await client.setIssueState(owner, repo, number, "closed", "completed");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await closeIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Closed linked source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to close linked source GitHub issue", message);
}
}
private async handleTaskDeleted(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise<void> {
/*
FNXC:CrossProcessDeleteObservation 2026-08-01-13:03:
Durable observers replay committed deletes at least once. GitHub close/delete/comment actions are
writer-owned side effects, so observed notifications are bridge-only and must never invoke them.
*/
if (meta?.observed) return;
if (task.githubTracking?.enabled !== true) {
await this.handleSourceIssueDelete(store, task, meta);
return;
}
const issue = task.githubTracking.issue;
if (!issue || !issue.owner || !issue.repo || !Number.isInteger(issue.number) || issue.number <= 0) {
await this.handleSourceIssueDelete(store, task, meta);
return;
}
const { owner, repo, number } = issue;
/*
FNXC:GitHubSourceIssueSplitClose 2026-08-01-09:24:
Tracking owns an identical source issue, including its split comment; otherwise each distinct
issue receives one comment then one close. The finally block reaches the source path even when
tracking metadata, auth, or a tracking mutation fails, preventing the old early-return strand.
*/
const trackingOwnsSourceIssue = sourceMatchesTrackingIssue(task, owner, repo, number);
try {
const githubIssueAction = meta?.githubIssueAction ?? "auto";
if (githubIssueAction === "leave") {
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked GitHub tracking issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
this.emitGitHubIssueAction(store, {
taskId: task.id,
action: githubIssueAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (githubIssueAction === "delete") {
try {
const deleteIssue = async () => {
await client.deleteIssue(owner, repo, number);
};
try {
await deleteIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await deleteIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Deleted linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to delete linked GitHub tracking issue", message);
}
return;
}
try {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked GitHub tracking issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
await postSplitCommentBeforeClose(
client, owner, repo, number, task.id, meta?.closureContext, githubIssueAction === "auto" ? "close" : githubIssueAction, false,
(message, details) => this.safeLogDeletedTaskEntry(store, task.id, message, details),
);
const closeIssue = async () => {
await client.setIssueState(owner, repo, number, "closed", "not_planned");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await closeIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Closed linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to close linked GitHub tracking issue", message);
}
} finally {
if (!trackingOwnsSourceIssue) {
await this.handleSourceIssueDelete(store, task, meta);
}
}
}
}