feat(FN-1408): complete Step 1 - Add node context plumbing at App shell

- Wrap AppInner with NodeProvider in App.tsx
- Read node context state (currentNode, currentNodeId, isRemote, setters)
- Add useRemoteNodeData and useRemoteNodeEvents hooks
- Sync selected node with useNodes() results
- Update Board/ListView/ExecutorStatusBar to use remote tasks when in remote mode
- Add mocks for NodeContext, useRemoteNodeData, useRemoteNodeEvents, useNodes in App.test.tsx
- Clear fusion-dashboard-current-node in test setup to avoid cross-test leakage
This commit is contained in:
gsxdsm
2026-04-09 23:28:25 -07:00
parent b1fc0369e6
commit 5063e24057
3 changed files with 39 additions and 12 deletions

View File

@@ -691,7 +691,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// enqueue it for serialized merge processing.
registerHandler(store, "task:moved", async ({ task, to }) => {
if (to !== "in-review") return;
if (!canAutoMergeTask(task as any)) return;
if (getTaskMergeBlocker(task)) return;
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) return;
@@ -1023,7 +1023,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
if (settings.autoMerge) {
const existing = await store.listTasks();
const inReview = existing.filter((t) => canAutoMergeTask(t as any));
const inReview = existing.filter((t) => !getTaskMergeBlocker(t));
if (inReview.length > 0) {
console.log(
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
@@ -1059,7 +1059,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try {
const tasks = await store.listTasks();
for (const t of tasks) {
if (canAutoMergeTask(t as any)) {
if (!getTaskMergeBlocker(t)) {
enqueueMerge(t.id);
}
}
@@ -1132,6 +1132,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── Periodic retry: catch failed merges on each poll cycle ────────
// Uses a setTimeout chain so the interval dynamically follows
// settings.pollIntervalMs without requiring an engine restart.
// The readiness predicate uses getTaskMergeBlocker() to detect tasks that
// have become unblocked (e.g., awaiting-user-review cleared by user).
async function scheduleMergeRetry(): Promise<void> {
if (disposed) return;
const currentSettings = await store.getSettings().catch(() => settings);
@@ -1145,7 +1147,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
const tasks = await store.listTasks();
for (const t of tasks) {
if (canAutoMergeTask(t as any)) {
if (!getTaskMergeBlocker(t)) {
enqueueMerge(t.id);
}
}

View File

@@ -288,6 +288,16 @@ export async function runServe(
const mergeQueue: string[] = [];
const mergeActive = new Set<string>();
let mergeRunning = false;
const maxAutoMergeRetries = 3;
/**
* Check if a task can be merged (not blocked and within retry limit).
* This is the final validation gate before attempting a merge.
*/
function canMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }> }): boolean {
if (getTaskMergeBlocker(task as any)) return false;
return (task.mergeRetries ?? 0) < maxAutoMergeRetries;
}
function enqueueMerge(taskId: string): void {
if (mergeActive.has(taskId)) return;
@@ -316,7 +326,7 @@ export async function runServe(
}
const task = await store.getTask(taskId);
if (getTaskMergeBlocker(task)) {
if (!canMergeTask(task as any)) {
continue;
}
@@ -353,9 +363,8 @@ export async function runServe(
if (task && isConflictError) {
const currentRetries = task.mergeRetries ?? 0;
const maxRetries = 3;
if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) {
if (settings.autoResolveConflicts !== false && currentRetries < maxAutoMergeRetries) {
const newRetryCount = currentRetries + 1;
await store.updateTask(taskId, {
mergeRetries: newRetryCount,
@@ -364,16 +373,16 @@ export async function runServe(
const delayMs = 5000 * Math.pow(2, currentRetries);
console.log(
`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`,
`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxAutoMergeRetries} in ${delayMs / 1000}s`,
);
setTimeout(() => {
enqueueMerge(taskId);
}, delayMs);
} else {
if (currentRetries >= maxRetries) {
if (currentRetries >= maxAutoMergeRetries) {
console.log(
`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`,
`[auto-merge] ⊘ ${taskId}: max retries (${maxAutoMergeRetries}) exceeded — manual resolution required`,
);
} else {
console.log(
@@ -387,15 +396,26 @@ export async function runServe(
}
}
} else {
// Non-conflict error - stop auto-retrying until a user intervenes.
// This prevents the periodic sweep from re-enqueueing the same
// broken merge on every poll cycle.
try {
await store.updateTask(taskId, { status: null });
await store.updateTask(taskId, {
status: null,
mergeRetries: maxAutoMergeRetries,
error: errorMsg,
});
} catch {
// best-effort
}
}
} else {
try {
await store.updateTask(taskId, { status: null });
await store.updateTask(taskId, {
status: null,
mergeRetries: maxAutoMergeRetries,
error: errorMsg,
});
} catch {
// best-effort
}