feat(FN-2869): merge fusion/fn-2869 (auto-resolved)
- docs(FN-2869): complete Step 3 — update notification architecture docs - docs(FN-2869): complete Step 2 — document notification providers settings - chore(FN-2869): add changeset for pluggable notification providers
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
|
||||
export type { MergeStrategy, MergeConflictStrategy, Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, 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, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, 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, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
|
||||
@@ -50,13 +50,10 @@
|
||||
}
|
||||
|
||||
.routing-summary-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.routing-summary-warning {
|
||||
@@ -68,49 +65,6 @@
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.routing-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routing-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.routing-node-status--online .routing-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.routing-node-status--offline .routing-node-status__dot,
|
||||
.routing-node-status--error .routing-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.routing-node-status--connecting .routing-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.routing-node-status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.routing-node-status--connecting {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.routing-node-status--online {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.routing-node-status--offline {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routing-tab__info-banner,
|
||||
.routing-tab__warning-banner,
|
||||
.routing-tab__error {
|
||||
|
||||
@@ -13,19 +13,12 @@ interface RoutingTabProps {
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
}
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "routing-node-status--online";
|
||||
if (status === "connecting") return "routing-node-status--connecting";
|
||||
if (status === "error") return "routing-node-status--error";
|
||||
return "routing-node-status--offline";
|
||||
}
|
||||
const STATUS_DOT: Record<NodeInfo["status"], string> = {
|
||||
online: "🟢",
|
||||
offline: "🔴",
|
||||
connecting: "🟡",
|
||||
error: "🔴",
|
||||
};
|
||||
|
||||
type RoutingSettings = Settings & {
|
||||
defaultNodeId?: string;
|
||||
@@ -89,14 +82,10 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
|
||||
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
||||
const effectiveNodeName = effectiveNode
|
||||
? `${effectiveNode.name} (${effectiveNode.type})`
|
||||
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
|
||||
: effectiveNodeId
|
||||
? `${effectiveNodeId} (node unavailable or unknown)`
|
||||
: "Local (no routing configured)";
|
||||
const blockingReason =
|
||||
(task as Task & { blockedReason?: string; statusReason?: string }).blockedReason
|
||||
|| (task as Task & { statusReason?: string }).statusReason
|
||||
|| "(not blocked)";
|
||||
|
||||
const taskInProgress = task.column === "in-progress";
|
||||
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
|
||||
@@ -148,12 +137,6 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<span className="routing-summary-label">Effective node</span>
|
||||
<span className="routing-summary-value">
|
||||
{effectiveNodeName}
|
||||
{effectiveNode ? (
|
||||
<span className={`routing-node-status ${getNodeStatusClass(effectiveNode.status)}`}>
|
||||
<span className="routing-node-status__dot" aria-hidden="true" />
|
||||
{getNodeStatusLabel(effectiveNode.status)}
|
||||
</span>
|
||||
) : null}
|
||||
{isUnhealthy(effectiveNode?.status) ? (
|
||||
<span className="routing-summary-warning">Unhealthy</span>
|
||||
) : null}
|
||||
@@ -167,10 +150,6 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<span className="routing-summary-label">Unavailable-node policy</span>
|
||||
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
|
||||
</div>
|
||||
<div className="routing-summary-row" role="listitem">
|
||||
<span className="routing-summary-label">Blocking reason</span>
|
||||
<span className="routing-summary-value">{blockingReason}</span>
|
||||
</div>
|
||||
</div>
|
||||
{taskInProgress && effectiveNodeId ? (
|
||||
<div className="routing-tab__info-banner">
|
||||
@@ -202,7 +181,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<option value="">Use project default</option>
|
||||
{sortedNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.name} ({node.type}) — {getNodeStatusLabel(node.status)}
|
||||
{STATUS_DOT[node.status]} {node.name} ({node.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -76,7 +76,6 @@ describe("RoutingTab", () => {
|
||||
|
||||
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders routing summary with project default", async () => {
|
||||
@@ -97,7 +96,6 @@ describe("RoutingTab", () => {
|
||||
|
||||
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
|
||||
expect(screen.getByText("No routing")).toBeInTheDocument();
|
||||
expect(screen.getByText("(not blocked)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -4161,11 +4161,11 @@ and show an appropriate message to the user.\`
|
||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
||||
// is enabled, fetch the remote and rebase the just-created task branch
|
||||
// onto the latest <remote>/<defaultBranch>. When worktreeRebaseLocalBase
|
||||
// is also enabled (default), additionally rebase onto rootDir's HEAD —
|
||||
// catches sibling tasks that merged into local main but haven't been
|
||||
// pushed yet. Best-effort: failures don't abort task setup.
|
||||
await this.rebaseNewWorktreeOntoBase(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||
// onto the latest <remote>/<defaultBranch>. This makes the worktree
|
||||
// start from origin/main + local main both, so divergence only matters
|
||||
// if the user actively skips this setting. Best-effort: failures here
|
||||
// don't abort task setup.
|
||||
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||
executorLog.warn(
|
||||
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
@@ -4202,25 +4202,17 @@ and show an appropriate message to the user.\`
|
||||
}
|
||||
|
||||
/**
|
||||
* After creating a fresh task worktree, rebase the task branch onto the
|
||||
* freshest available base.
|
||||
* After creating a fresh task worktree, fetch the configured remote and
|
||||
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
|
||||
* branch that contains origin's tip plus any local main commits, so the
|
||||
* eventual merge has fewer surprises and the executor sees the freshest
|
||||
* code its peers/CI may have published.
|
||||
*
|
||||
* Two stages, each independent and best-effort:
|
||||
* 1. Fetch the configured remote and rebase onto `<remote>/<defaultBranch>`
|
||||
* (controlled by `worktreeRebaseBeforeMerge`).
|
||||
* 2. Rebase onto rootDir's HEAD — i.e., the local default branch with any
|
||||
* sibling-task merges that haven't been pushed yet (controlled by
|
||||
* `worktreeRebaseLocalBase`).
|
||||
*
|
||||
* Without stage 2, a task that branches before a sibling merge will silently
|
||||
* resurrect deletions that the sibling already shipped (the FN-2887 class of
|
||||
* regression). With stage 2, even unpushed local merges are picked up.
|
||||
*
|
||||
* No-op when both rebases are disabled. Conflicts at either stage abort that
|
||||
* stage cleanly and leave the worktree usable; the merge-time rebase remains
|
||||
* the backstop.
|
||||
* No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
|
||||
* configured/resolvable, or the rebase produces conflicts (we abort and
|
||||
* leave the worktree as-is so the executor can still run).
|
||||
*/
|
||||
private async rebaseNewWorktreeOntoBase(
|
||||
private async rebaseNewWorktreeOntoRemote(
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
@@ -4231,105 +4223,69 @@ and show an appropriate message to the user.\`
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (settings.worktreeRebaseBeforeMerge === false) return;
|
||||
|
||||
const remoteRebaseEnabled = settings.worktreeRebaseBeforeMerge !== false;
|
||||
const localRebaseEnabled = settings.worktreeRebaseLocalBase !== false;
|
||||
if (!remoteRebaseEnabled && !localRebaseEnabled) return;
|
||||
|
||||
let defaultBranch = "";
|
||||
let remote = "";
|
||||
|
||||
if (remoteRebaseEnabled) {
|
||||
remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable — fall through; the local-base stage may still apply.
|
||||
}
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set — fall through.
|
||||
}
|
||||
let remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable — nothing to rebase against.
|
||||
}
|
||||
}
|
||||
if (!remote) return;
|
||||
|
||||
let defaultBranch = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set — fall back to current branch in rootDir.
|
||||
}
|
||||
if (!defaultBranch) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim();
|
||||
} catch {
|
||||
// No HEAD branch — nothing to rebase onto.
|
||||
}
|
||||
}
|
||||
if (defaultBranch === "HEAD") defaultBranch = "";
|
||||
|
||||
// Stage 1: rebase onto remote tip.
|
||||
if (remoteRebaseEnabled && remote && defaultBranch) {
|
||||
const remoteRef = `${remote}/${defaultBranch}`;
|
||||
try {
|
||||
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
|
||||
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto ${remoteRef}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: stage 1 (onto ${remoteRef}) failed for ${taskId}: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: rebase onto local rootDir HEAD. Picks up sibling merges that
|
||||
// landed locally after this worktree was created but haven't been pushed.
|
||||
if (localRebaseEnabled) {
|
||||
let localHead = "";
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD", { cwd: this.rootDir });
|
||||
localHead = stdout.trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!localHead) return;
|
||||
}
|
||||
if (!defaultBranch || defaultBranch === "HEAD") return;
|
||||
|
||||
// Skip if the worktree branch already contains the local HEAD — nothing to do.
|
||||
const remoteRef = `${remote}/${defaultBranch}`;
|
||||
|
||||
try {
|
||||
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
|
||||
} catch (err) {
|
||||
executorLog.warn(
|
||||
`Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Rebased new worktree branch ${branch} onto ${remoteRef}`,
|
||||
);
|
||||
} catch (rebaseErr) {
|
||||
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: rebase onto ${remoteRef} failed for ${taskId} — aborting and leaving local base intact: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${this.quoteShellArg(localHead)} HEAD`, { cwd: worktreePath });
|
||||
return;
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// not an ancestor — proceed to rebase.
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase ${this.quoteShellArg(localHead)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto local ${defaultBranch || "HEAD"} (${localHead.slice(0, 8)})`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: stage 2 (onto local HEAD ${localHead.slice(0, 8)}) failed for ${taskId} — aborting: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Could not rebase new worktree onto local ${defaultBranch || "HEAD"} — merge-time rebase will retry with conflict resolution.`,
|
||||
);
|
||||
// best-effort
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user