From 3793b576d8d2d35e6c0cae28d4df3f3ba828e8be Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 02:47:25 -0700 Subject: [PATCH] FN-8673: comment on split source issue closures Explain GitHub source-issue closures when imported work is split into subtasks. - carry split closure context through task deletion and triage - post one explanatory comment before closing source and tracking GitHub issues - preserve exactly-one-comment behavior when close retries after transient failures - document split closure behavior and cover source/tracking scenarios Files changed: .changeset/fn-8673-split-close-issue-comment.md | 7 ++ docs/settings-reference.md | 2 +- docs/task-management.md | 1 + .../task-delete-caller-attribution.test.ts | 32 +++++++ packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 10 +-- packages/core/src/task-delete-attribution.ts | 16 ++++ .../core/src/task-store/archive-lifecycle-2.ts | 14 ++-- packages/core/src/task-store/archive-lifecycle.ts | 6 +- packages/core/src/types.ts | 13 +++ .../src/__tests__/github-tracking-state.test.ts | 98 +++++++++++++++++++++- packages/dashboard/src/github-tracking-state.ts | 82 +++++++++++++++--- packages/engine/src/__tests__/triage.test.ts | 4 + packages/engine/src/triage.ts | 10 +++ 14 files changed, 268 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-8673 Fusion-Task-Lineage: 904c2445-64d9-47b9-b706-f64b23c4e3a6 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8673-split-close-issue-comment.md | 7 ++ docs/settings-reference.md | 2 +- docs/task-management.md | 1 + .../task-delete-caller-attribution.test.ts | 32 ++++++ packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 10 +- packages/core/src/task-delete-attribution.ts | 16 +++ .../src/task-store/archive-lifecycle-2.ts | 14 ++- .../core/src/task-store/archive-lifecycle.ts | 6 +- packages/core/src/types.ts | 13 +++ .../__tests__/github-tracking-state.test.ts | 100 +++++++++++++++++- .../dashboard/src/github-tracking-state.ts | 82 ++++++++++++-- packages/engine/src/__tests__/triage.test.ts | 4 + packages/engine/src/triage.ts | 10 ++ 14 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-8673-split-close-issue-comment.md diff --git a/.changeset/fn-8673-split-close-issue-comment.md b/.changeset/fn-8673-split-close-issue-comment.md new file mode 100644 index 0000000000..f6efd9ea4c --- /dev/null +++ b/.changeset/fn-8673-split-close-issue-comment.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Explain GitHub issue closures when triage splits imported work into subtasks. +category: feature +dev: Adds typed split closure context to delete events and ids-only run-audit metadata. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index bed79c0035..c2cd7d6431 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -676,7 +676,7 @@ Default notes: | `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. | | `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. Suppressed when the source issue is also the task's *tracked* issue (`githubTracking.enabled` with the same `owner/repo#number`): the GitHub tracking comment already reports completion there, with commit/branch/PR/files details, so the issue would otherwise receive two comments. In that case `githubCommentTemplate` is not used and the task log records `Skipped GitHub issue completion comment`. When tracking points at a *different* issue, both issues are commented as before. | | `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. When the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), Fusion appends a `Current version: v` line and a `Target release: v` line (next-minor bump, patch reset to 0, e.g. `0.55.0` → `0.56.0`), resolved via the published `@runfusion/fusion` CLI package version. If that version is unresolved/unparseable, the base comment is posted with no version lines. Comments on every other repository are byte-for-byte unchanged. | -| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. | +| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. Separately, a triage split-close comments with the child task IDs immediately before closing its imported issue; the same source/tracking issue is handled once by its owning path. | | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `sessionAdvisorEnabledByDefault` | `boolean` | `false` | Project-level default for the session advisor (LLM overseer agent that reviews live executor transcripts). Off by default (opt-in). Quick Add exposes an eye toggle next to GitHub that inherits this default; each task can override via `sessionAdvisorEnabled`. Provider and model ids still come from workflow settings (`plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId`). Dashboard location: **Settings → Project → General → Session advisor (overseer agent)**. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | diff --git a/docs/task-management.md b/docs/task-management.md index e1ae03311e..7bee1f97f6 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -736,6 +736,7 @@ Import issues: - GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies. - When `githubCloseSourceIssueOnDone` is enabled (default: `false`), Fusion also closes linked source-imported GitHub issues with `state_reason: completed` when the task moves into `done`. On startup, a bounded reconciliation sweep checks done tasks and closes any still-open source issue links that were missed due to transient failures. +- When triage splits an imported GitHub task into subtasks and closes the parent, Fusion comments on each affected source/tracking issue with the parent and child task IDs immediately before closing it. A source and tracking link to the same issue have one deterministic owner, so that issue receives exactly one comment and one close; different issues each receive one of both. The `task:deleted` run-audit row records only `closureKind` and `closureChildTaskIds` (no comment prose). This notification is delivered by the deleting store's in-process event; PostgreSQL does not support cross-process observation of `task:deleted` through the legacy SQLite polling path. ```bash fn task import owner/repo --labels bug --limit 20 diff --git a/packages/core/src/__tests__/task-delete-caller-attribution.test.ts b/packages/core/src/__tests__/task-delete-caller-attribution.test.ts index 129000ca97..03aad04070 100644 --- a/packages/core/src/__tests__/task-delete-caller-attribution.test.ts +++ b/packages/core/src/__tests__/task-delete-caller-attribution.test.ts @@ -62,6 +62,7 @@ import { resolveHttpDeleteCallerKind, type TaskDeleteAuditContext, } from "../task-delete-attribution.js"; +import type { TaskDeleteClosureContext } from "../types.js"; import type { Task } from "../types.js"; function createTask(id: string): Task { @@ -112,6 +113,7 @@ function makeDeleteStore(task: Task) { auditEvents.push(event); }), makeSyntheticDeleteRunId: vi.fn((id: string) => `synthetic-delete-${id}`), + laneCache: { invalidate: vi.fn() }, withTaskLock: vi.fn(async (_id: string, fn: () => Promise) => fn()), emit: vi.fn((event: string, ...args: unknown[]) => events.emit(event, ...args)), on: events.on.bind(events), @@ -229,6 +231,36 @@ describe("task:deleted caller attribution", () => { Run-audit metadata is ids/counts/outcomes-only. Guard that attribution never smuggles prose or a user-agent string in: the two added fields must be a member of the closed union and a task id. */ + it("emits and audits split closure context without changing ordinary deletes", async () => { + const task = createTask("FN-SPLIT"); + const store = makeDeleteStore(task); + const closureContext: TaskDeleteClosureContext = { + kind: "split-into-subtasks", + childTaskIds: ["FN-CHILD-1", "FN-CHILD-2"], + }; + const deleted = vi.fn(); + store.on("task:deleted", deleted); + + await deleteTaskImpl(store as never, task.id, { closureContext }); + + expect(deleted).toHaveBeenCalledWith(task, { + githubIssueAction: "auto", + closureContext, + }); + expect(store.deletedAuditRow()?.metadata).toMatchObject({ + closureKind: "split-into-subtasks", + closureChildTaskIds: ["FN-CHILD-1", "FN-CHILD-2"], + }); + + const ordinaryStore = makeDeleteStore(createTask("FN-ORDINARY")); + const ordinaryDeleted = vi.fn(); + ordinaryStore.on("task:deleted", ordinaryDeleted); + await deleteTaskImpl(ordinaryStore as never, "FN-ORDINARY"); + expect(ordinaryDeleted.mock.calls[0]?.[1]).toEqual({ githubIssueAction: "auto" }); + expect(ordinaryStore.deletedAuditRow()?.metadata).not.toHaveProperty("closureKind"); + expect(ordinaryStore.deletedAuditRow()?.metadata).not.toHaveProperty("closureChildTaskIds"); + }); + it("records only enum/id attribution values", () => { const fields = buildDeleteCallerAuditFields({ agentId: "pi-extension", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d8bf9b60d..3c1808c775 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, APPROVAL_REQUEST_PENDING_TTL_MS, APPROVAL_REQUEST_GRANT_TTL_MS, isApprovalRequestExpired, configureApprovalRequestTtls, getApprovalRequestGrantTtlMs, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, TaskDeleteClosureContext, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export type { NativeStructureRef, NativeStructureEmbed, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js"; export type { SymbolLockStatus, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 55b236457a..2f139a4070 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { and, eq, isNull, ne, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import { type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, TaskDeleteClosureContext, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js"; export type OverlapBlockerRepairReason = | "task-not-found" @@ -169,7 +169,7 @@ export interface TaskStoreEvents { unchanged; absent metadata is unknown, never a legacy-lane claim. */ "task:updated": [task: Task, meta?: { lanes?: TaskMoveLanes }]; - "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; + "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext }]; "task:merged": [result: MergeResult]; "settings:updated": [data: { settings: Settings; previous: Settings }]; "workflow:setting-values-updated": [data: { @@ -2206,7 +2206,7 @@ export class TaskStore extends EventEmitter { /** * FNXC:RuntimeLifecycleAsync 2026-06-24-12:05: */ - public async deleteTaskBackend( id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext; }, ): Promise { + public async deleteTaskBackend( id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext; }, ): Promise { return deleteTaskBackendImpl(this, id, options); } @@ -2217,13 +2217,13 @@ export class TaskStore extends EventEmitter { */ public async recordRunAuditEventBackend( tx: DbTransaction, event: { domain: string; mutationType: string; target: string; taskId: string; agentId: string; runId: string; metadata: Record; }, ): Promise { return recordRunAuditEventBackendImpl(this, tx, event); } - async deleteTask( id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext; }, ): Promise { + async deleteTask( id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext; }, ): Promise { return deleteTaskImpl(this, id, options); } async deleteTaskIf( id: string, predicate: (live: Task) => boolean | Promise, - options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext }, + options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext }, ): Promise { /* FNXC:SqliteDualPathCleanup 2026-07-26-14:05: diff --git a/packages/core/src/task-delete-attribution.ts b/packages/core/src/task-delete-attribution.ts index effbaff521..933bc8f268 100644 --- a/packages/core/src/task-delete-attribution.ts +++ b/packages/core/src/task-delete-attribution.ts @@ -103,3 +103,19 @@ export function buildDeleteCallerAuditFields( callerTaskId: auditContext?.taskId ?? null, }; } + +/** + * FNXC:GitHubSourceIssueSplitClose 2026-08-01-09:24: + * A split-close audit row records the closed-vocabulary reason and child task ids, never the + * explanatory GitHub comment prose. Omission deliberately returns no fields so ordinary deletes + * retain their existing audit shape. + */ +export function buildDeleteClosureAuditFields( + closureContext: import("./types.js").TaskDeleteClosureContext | undefined, +): Record { + if (!closureContext || closureContext.kind !== "split-into-subtasks") return {}; + return { + closureKind: closureContext.kind, + closureChildTaskIds: closureContext.childTaskIds, + }; +} diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index d850b7c4c9..d4d30c6961 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -16,8 +16,8 @@ import {mkdir, writeFile} from "node:fs/promises"; import {join} from "node:path"; import {and, eq} from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; -import type {Task, Column, ArchivedTaskEntry, GithubIssueAction} from "../types.js"; -import {buildDeleteCallerAuditFields, type TaskDeleteAuditContext} from "../task-delete-attribution.js"; +import type {Task, Column, ArchivedTaskEntry, GithubIssueAction, TaskDeleteClosureContext} from "../types.js"; +import {buildDeleteCallerAuditFields, buildDeleteClosureAuditFields, type TaskDeleteAuditContext} from "../task-delete-attribution.js"; import {notifyOperatorOfNonOperatorDelete} from "../task-delete-notice.js"; import "../builtin-traits.js"; import {normalizeTaskPriority} from "../task-priority.js"; @@ -137,7 +137,7 @@ export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archi }; } -export async function deleteTaskBackendImpl(store: TaskStore, id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext; },): Promise { +export async function deleteTaskBackendImpl(store: TaskStore, id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext; },): Promise { /* FNXC:TaskDeletion 2026-07-01-00:00: Task-bound runtime callers may never soft-delete the task they are executing; this guard is the PostgreSQL-backend mirror of the SQLite-path guard in deleteTaskImpl so direct callers of deleteTaskBackend inherit the same invariant before any mutation or audit. @@ -219,13 +219,17 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option // FNXC:TaskDeleteAttribution 2026-07-26-14:30: caller class + calling // task id; `taskId` reached this function but was never persisted. ...buildDeleteCallerAuditFields(options?.auditContext), + ...buildDeleteClosureAuditFields(options?.closureContext), }, }); }); // Emit lifecycle event (best-effort, outside the transaction). store.laneCache.invalidate(task.id); - store.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" }); + store.emit("task:deleted", task, { + githubIssueAction: options?.githubIssueAction ?? "auto", + ...(options?.closureContext ? { closureContext: options.closureContext } : {}), + }); /* FNXC:TaskDeleteNotice 2026-07-26-16:10: Operator mailbox notice for a delete the operator did not perform. Deliberately placed here, @@ -247,7 +251,7 @@ export async function deleteTaskIfBackendImpl( store: TaskStore, id: string, predicate: (live: Task) => boolean | Promise, - options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext }, + options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext }, ): Promise<{ task: Task; deleted: boolean }> { if (options?.auditContext?.taskId === id) throw new TaskSelfDeleteError(id); return store.withTaskLock(id, async () => { diff --git a/packages/core/src/task-store/archive-lifecycle.ts b/packages/core/src/task-store/archive-lifecycle.ts index 5f0dc5ab73..717ba37e4c 100644 --- a/packages/core/src/task-store/archive-lifecycle.ts +++ b/packages/core/src/task-store/archive-lifecycle.ts @@ -8,7 +8,7 @@ */ import {TaskStore, storeLog} from "../store.js"; import {TaskSelfDeleteError} from "./errors.js"; -import {isWorkspaceTask, type Task, type GithubIssueAction} from "../types.js"; +import {isWorkspaceTask, type Task, type GithubIssueAction, type TaskDeleteClosureContext} from "../types.js"; import {type TaskDeleteAuditContext} from "../task-delete-attribution.js"; import "../builtin-traits.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; @@ -187,7 +187,7 @@ function _scheduleDeleteBranchCleanup(store: TaskStore, task: Task): void { })(); } -export async function deleteTaskImpl(store: TaskStore, id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext; },): Promise { +export async function deleteTaskImpl(store: TaskStore, id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext; },): Promise { // FNXC:RuntimeLifecycleAsync 2026-06-24-12:00: // Backend-mode deleteTask: delegate the core async operations (task read, // lineage gate, lineage clear, soft-delete, audit) to the async helpers. @@ -221,7 +221,7 @@ export async function deleteTaskIfImpl( store: TaskStore, id: string, predicate: (live: Task) => boolean | Promise, - options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext }, + options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext; auditContext?: TaskDeleteAuditContext }, ): Promise { if (options?.auditContext?.taskId === id) throw new TaskSelfDeleteError(id); /* diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6753693621..51ab69a803 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -325,6 +325,19 @@ export type { TaskSourceIssue, }; +/* +FNXC:GitHubSourceIssueSplitClose 2026-08-01-09:24: +When triage closes an imported parent after splitting it into child tasks, the authoritative +in-process `task:deleted` event carries only this typed, ids-only reason so the GitHub owner can +explain the close. PostgreSQL cannot observe the SQLite polling replica path, so this context is +intentionally delivered only by the deleting store instance; cross-process delivery needs a separate +outbox or event bridge. +*/ +export interface TaskDeleteClosureContext { + kind: "split-into-subtasks"; + childTaskIds: string[]; +} + export interface BatchStatusRequest { taskIds: string[]; } diff --git a/packages/dashboard/src/__tests__/github-tracking-state.test.ts b/packages/dashboard/src/__tests__/github-tracking-state.test.ts index 1a6f6d6583..31ad07b26a 100644 --- a/packages/dashboard/src/__tests__/github-tracking-state.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-state.test.ts @@ -3,10 +3,11 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { TaskStore } from "@fusion/core"; import { decideIssueAction, GitHubTrackingStateService } from "../github-tracking-state.js"; -const { mockSetIssueState, mockDeleteIssue, mockGetIssue } = vi.hoisted(() => ({ +const { mockSetIssueState, mockDeleteIssue, mockGetIssue, mockCommentOnIssue } = vi.hoisted(() => ({ mockSetIssueState: vi.fn(), mockDeleteIssue: vi.fn(), mockGetIssue: vi.fn(), + mockCommentOnIssue: vi.fn(), })); const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({ @@ -18,6 +19,7 @@ vi.mock("../github.js", () => ({ setIssueState: (...args: unknown[]) => mockSetIssueState(...args), deleteIssue: (...args: unknown[]) => mockDeleteIssue(...args), getIssue: (...args: unknown[]) => mockGetIssue(...args), + commentOnIssue: (...args: unknown[]) => mockCommentOnIssue(...args), }; }), })); @@ -648,14 +650,102 @@ describe("GitHubTrackingStateService", () => { expect(mockDeleteIssue).not.toHaveBeenCalled(); }); - it("prefers tracking branch when both tracking and source issue exist", async () => { + it("comments before closing a split source issue when tracking is disabled", async () => { service.start(); - - store.emit("task:deleted", createTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42 } }), { githubIssueAction: "close" }); + store.emit("task:deleted", createSourceTask(), { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-1", "FN-2"] }, + }); await flushAsync(); + expect(mockCommentOnIssue).toHaveBeenCalledWith("acme", "widgets", 42, expect.stringContaining("FN-source")); + expect(mockCommentOnIssue).toHaveBeenCalledWith("acme", "widgets", 42, expect.stringContaining("FN-1, FN-2")); + expect(mockCommentOnIssue.mock.invocationCallOrder[0]).toBeLessThan(mockSetIssueState.mock.invocationCallOrder[0]); + }); + + it("still closes when the split comment fails", async () => { + mockCommentOnIssue.mockRejectedValueOnce(new Error("comment failed")); + service.start(); + store.emit("task:deleted", createSourceTask(), { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] }, + }); + await flushAsync(); + + expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed"); + expect(store.logEntry).toHaveBeenCalledWith("FN-source", "Failed to post GitHub source issue split comment", "comment failed"); + }); + + it.each([ + { label: "source", task: createSourceTask() }, + { label: "tracking", task: createTask() }, + ])("posts the split comment once when a transient $label close retries", async ({ task }) => { + mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET")); + mockSetIssueState.mockResolvedValueOnce(undefined); + service.start(); + store.emit("task:deleted", task, { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] }, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); + expect(mockSetIssueState).toHaveBeenCalledTimes(2); + }); + + it("does not comment for ordinary, leave, delete, or already closed deletes", async () => { + service.start(); + store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" }); + store.emit("task:deleted", createSourceTask({ id: "FN-leave" }), { githubIssueAction: "leave", closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] } }); + store.emit("task:deleted", createSourceTask({ id: "FN-delete" }), { githubIssueAction: "delete", closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] } }); + await flushAsync(); + + expect(mockCommentOnIssue).not.toHaveBeenCalled(); + }); + + it("gives one same issue to tracking, with one comment before one close", async () => { + service.start(); + store.emit("task:deleted", createTask({ sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 42 } }), { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] }, + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); + expect(mockSetIssueState).toHaveBeenCalledTimes(1); + expect(mockCommentOnIssue.mock.invocationCallOrder[0]).toBeLessThan(mockSetIssueState.mock.invocationCallOrder[0]); + }); + + it("handles distinct tracking and source issues independently", async () => { + service.start(); + store.emit("task:deleted", createTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 7 } }), { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] }, + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(2); + expect(mockSetIssueState).toHaveBeenCalledTimes(2); + for (const callOrder of mockCommentOnIssue.mock.invocationCallOrder) { + expect(callOrder).toBeLessThan(Math.max(...mockSetIssueState.mock.invocationCallOrder)); + } expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "not_planned"); - expect(mockSetIssueState).not.toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed"); + expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 7, "closed", "completed"); + }); + + it("reaches a valid source issue when tracking metadata is incomplete", async () => { + service.start(); + store.emit("task:deleted", createTask({ + githubTracking: { enabled: true, issue: { owner: "owner", repo: "", number: 42 } }, + sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 7 }, + }), { + githubIssueAction: "close", + closureContext: { kind: "split-into-subtasks", childTaskIds: ["FN-CHILD"] }, + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledWith("acme", "widgets", 7, expect.any(String)); + expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 7, "closed", "completed"); }); }); diff --git a/packages/dashboard/src/github-tracking-state.ts b/packages/dashboard/src/github-tracking-state.ts index 572408716b..f7aa875afc 100644 --- a/packages/dashboard/src/github-tracking-state.ts +++ b/packages/dashboard/src/github-tracking-state.ts @@ -1,7 +1,7 @@ import { createLogger } from "@fusion/core"; const severityAuditLog = createLogger("dashboard-github-tracking-state"); -import type { GithubIssueAction, GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core"; +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"; @@ -139,11 +139,55 @@ type GitHubIssueActionEvent = { error?: string; }; +type TaskDeletedMeta = { githubIssueAction?: GithubIssueAction; closureContext?: TaskDeleteClosureContext }; + +type SplitCommentLog = (message: string, details: string) => Promise; + +/* +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 { + 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 void; - onTaskDeleted: (task: Task, meta?: { githubIssueAction?: GithubIssueAction }) => void; + onTaskDeleted: (task: Task, meta?: TaskDeletedMeta) => void; }>(); private started = false; @@ -173,7 +217,7 @@ export class GitHubTrackingStateService { const onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(store, event); }; - const onTaskDeleted = (task: Task, meta?: { githubIssueAction?: GithubIssueAction }): void => { + const onTaskDeleted = (task: Task, meta?: TaskDeletedMeta): void => { void this.handleTaskDeleted(store, task, meta); }; this.listeners.set(store, { onTaskMoved, onTaskDeleted }); @@ -358,7 +402,7 @@ export class GitHubTrackingStateService { } } - private async handleSourceIssueDelete(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise { + private async handleSourceIssueDelete(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise { const sourceIssue = task.sourceIssue; if (sourceIssue?.provider !== "github") { return; @@ -449,6 +493,10 @@ export class GitHubTrackingStateService { 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"); @@ -473,22 +521,27 @@ export class GitHubTrackingStateService { } } - private async handleTaskDeleted(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise { + private async handleTaskDeleted(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise { if (task.githubTracking?.enabled !== true) { await this.handleSourceIssueDelete(store, task, meta); return; } const issue = task.githubTracking.issue; - if (!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; - if (!owner || !repo || !number) { - return; - } + /* + 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}`); @@ -549,6 +602,10 @@ export class GitHubTrackingStateService { 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"); }; @@ -570,5 +627,10 @@ export class GitHubTrackingStateService { 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); + } + } } } diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 5ad0c8be05..055d4dfdaf 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -4218,6 +4218,10 @@ describe("taskCreate tool model inheritance", () => { ); expect(store.deleteTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({ removeLineageReferences: true, + closureContext: { + kind: "split-into-subtasks", + childTaskIds: ["FN-501", "FN-502"], + }, auditContext: expect.objectContaining({ agentId: "triage", runId: expect.stringMatching(/^triage-delete-FN-500-/), diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 34785901e5..db370af4b4 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -3003,8 +3003,18 @@ export class TriageProcessor { ); try { // FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent. + /* + FNXC:GitHubSourceIssueSplitClose 2026-08-01-09:24: + The imported issue reporter needs to learn that this parent closed in favor of these + child tasks. Preserve the exact ids as typed delete context so the in-process GitHub + lifecycle owner can comment immediately before its close. + */ await this.store.deleteTask(task.id, { removeLineageReferences: true, + closureContext: { + kind: "split-into-subtasks", + childTaskIds: [...createdSubtasksRef.current], + }, auditContext: { // FNXC:TaskDeleteAttribution 2026-07-26-14:30: labelling only — this // split-close delete is intended engine behavior and is unchanged.