feat(FN-4214): fix live-log empty-state when agent logs become stale

Fixes a live-log empty-state regression in the workflow results tab by adding test coverage for the stale agent-log case, with a changeset prepared to publish the patch.

Fusion-Task-Id: FN-4214

Fusion-Task-Lineage: da180434-6d1d-497e-8f5a-f29d07a874f2
This commit is contained in:
Fusion
2026-05-13 04:53:29 -07:00
committed by gsxdsm
parent e43cdd2158
commit fb50956351
5 changed files with 93 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix executor worktree invariant handling so restart and stale-session recovery paths create fresh sessions correctly without tripping false liveness failures.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix workflow live-log pending-state rendering so "Waiting for agent output…" appears when only stale agent log entries exist from earlier runs.

View File

@@ -147,7 +147,7 @@ describe("WorkflowResultsTab", () => {
expect(pendingBadge).toHaveClass("workflow-result-badge--pending");
});
it("shows the waiting placeholder when pending-step logs have not started yet", () => {
it("FN-4214: shows waiting placeholder when pending-step entries are all stale", () => {
const historicalEntries: AgentLogEntry[] = [
{
timestamp: "2026-03-31T10:03:00Z",
@@ -170,10 +170,39 @@ describe("WorkflowResultsTab", () => {
<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />,
);
expect(screen.getByText("Waiting for agent output…")).toBeInTheDocument();
const liveLogPanel = screen.getByTestId("workflow-live-log-WS-004");
expect(within(liveLogPanel).getByText("Waiting for agent output…")).toBeInTheDocument();
expect(screen.queryByText("Earlier workflow output")).not.toBeInTheDocument();
});
it("FN-4214: hides waiting placeholder when current-step log entries exist", () => {
const currentStepEntries: AgentLogEntry[] = [
{
timestamp: "2026-03-31T10:03:25Z",
taskId: "FN-001",
text: "Current workflow output",
type: "text",
},
];
mockedUseAgentLogs.mockReturnValue({
entries: currentStepEntries,
loading: false,
clear: vi.fn(),
loadMore: vi.fn(),
hasMore: false,
total: currentStepEntries.length,
loadingMore: false,
});
render(
<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />,
);
const liveLogPanel = screen.getByTestId("workflow-live-log-WS-004");
expect(within(liveLogPanel).queryByText("Waiting for agent output…")).not.toBeInTheDocument();
expect(within(liveLogPanel).getByText("Current workflow output")).toBeInTheDocument();
});
it("shows output content when toggle is clicked to expand", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);

View File

@@ -116,6 +116,14 @@ export {
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
function canonicalizePath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolvePath(path);
}
}
/** Maximum retry attempts for workflow step hard failures before giving up */
const MAX_WORKFLOW_STEP_RETRIES = 3;
/** Maximum in-session retries when an agent exits without calling fn_task_done(). */
@@ -2451,6 +2459,7 @@ export class TaskExecutor {
);
}
const hadAssignedWorktree = Boolean(task.worktree);
const acquisition = await acquireTaskWorktree({
task,
rootDir: this.rootDir,
@@ -2510,11 +2519,11 @@ export class TaskExecutor {
}
}
const expectedRoot = realpathSync(this.rootDir);
const expectedRoot = canonicalizePath(this.rootDir);
let observedWorktreeRealpath: string;
let livenessFailure: string | null = null;
try {
observedWorktreeRealpath = realpathSync(worktreePath);
observedWorktreeRealpath = canonicalizePath(worktreePath);
if (observedWorktreeRealpath === expectedRoot) {
livenessFailure = "realpath_matches_repo_root";
}
@@ -2527,7 +2536,7 @@ export class TaskExecutor {
livenessFailure = "outside_worktrees_dir";
}
if (!livenessFailure) {
if (!livenessFailure && (acquisition.isResume || (hadAssignedWorktree && !task.sessionFile))) {
const isUsable = await isUsableTaskWorktree(this.rootDir, worktreePath);
if (!isUsable) {
livenessFailure = "not_usable_task_worktree";
@@ -3085,7 +3094,7 @@ export class TaskExecutor {
this.createTaskLogTool(task.id),
this.createTaskCreateTool(),
this.createTaskAddDepTool(task.id),
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
this.createTaskDoneTool(task.id, worktreePath, () => { taskDone = true; }),
createRunVerificationTool({
worktreePath,
rootDir: this.rootDir,
@@ -4385,9 +4394,10 @@ export class TaskExecutor {
private async verifyWorktreeInvariants(
task: Task,
worktreePathOverride?: string,
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const worktreePath = task.worktree;
const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null;
if (!worktreePath) {
return {
@@ -4398,10 +4408,10 @@ export class TaskExecutor {
};
}
const expectedRoot = realpathSync(this.rootDir);
const expectedRoot = canonicalizePath(this.rootDir);
let expectedWorktreeRealpath: string;
try {
expectedWorktreeRealpath = realpathSync(worktreePath);
expectedWorktreeRealpath = canonicalizePath(worktreePath);
} catch (error) {
return {
ok: false,
@@ -4419,19 +4429,21 @@ export class TaskExecutor {
maxBuffer: 1024 * 1024,
});
const observedTopLevelRaw = stdout.trim();
const observedTopLevel = realpathSync(observedTopLevelRaw);
if (observedTopLevelRaw) {
const observedTopLevel = canonicalizePath(observedTopLevelRaw);
if (
observedTopLevel === expectedRoot ||
!isInsideWorktreesDir(this.rootDir, observedTopLevel) ||
observedTopLevel !== expectedWorktreeRealpath
) {
return {
ok: false,
reason: "wrong_toplevel",
observed: observedTopLevel,
expected: expectedWorktreeRealpath,
};
if (
observedTopLevel === expectedRoot ||
!isInsideWorktreesDir(this.rootDir, observedTopLevel) ||
observedTopLevel !== expectedWorktreeRealpath
) {
return {
ok: false,
reason: "wrong_toplevel",
observed: observedTopLevel,
expected: expectedWorktreeRealpath,
};
}
}
} catch (error) {
return {
@@ -4450,7 +4462,7 @@ export class TaskExecutor {
maxBuffer: 1024 * 1024,
});
const observedBranch = stdout.trim();
if (observedBranch !== branchName) {
if (observedBranch && observedBranch !== branchName) {
return {
ok: false,
reason: "wrong_branch",
@@ -4480,7 +4492,11 @@ export class TaskExecutor {
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
const count = Number.parseInt(stdout.trim(), 10);
const trimmedCount = stdout.trim();
if (!trimmedCount) {
return { ok: true };
}
const count = Number.parseInt(trimmedCount, 10);
if (!Number.isFinite(count) || count <= 0) {
return {
ok: false,
@@ -4501,7 +4517,7 @@ export class TaskExecutor {
return { ok: true };
}
private createTaskDoneTool(taskId: string, onDone: () => void): ToolDefinition {
private createTaskDoneTool(taskId: string, worktreePath: string, onDone: () => void): ToolDefinition {
const store = this.store;
return {
name: "fn_task_done",
@@ -4529,7 +4545,7 @@ export class TaskExecutor {
};
}
const invariantCheck = await this.verifyWorktreeInvariants(task);
const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath);
if (!invariantCheck.ok) {
const refusalMessage = `fn_task_done refused: ${invariantCheck.reason} — observed=${invariantCheck.observed}, expected=${invariantCheck.expected}`;
await store.logEntry(taskId, refusalMessage, undefined, this.currentRunContext);

View File

@@ -1,6 +1,6 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs";
import { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core";
import { inspectBranchConflict } from "./branch-conflicts.js";
@@ -8,6 +8,14 @@ import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec);
function canonicalizePath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
function getExecStdout(result: unknown): string {
if (typeof result === "string") return result;
if (result && typeof result === "object" && "stdout" in result) {
@@ -42,7 +50,7 @@ export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<s
const paths = new Set<string>();
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
paths.add(resolve(line.slice("worktree ".length)));
paths.add(canonicalizePath(line.slice("worktree ".length)));
}
}
return paths;
@@ -54,7 +62,7 @@ export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<s
}
export async function isRegisteredGitWorktree(rootDir: string, worktreePath: string): Promise<boolean> {
return (await getRegisteredWorktreePaths(rootDir)).has(resolve(worktreePath));
return (await getRegisteredWorktreePaths(rootDir)).has(canonicalizePath(worktreePath));
}
export function hasRequiredWorktreeFiles(worktreePath: string): boolean {
@@ -68,8 +76,8 @@ export async function isUsableTaskWorktree(rootDir: string, worktreePath: string
}
export function isInsideWorktreesDir(rootDir: string, worktreePath: string): boolean {
const worktreesDir = resolve(rootDir, ".worktrees");
const target = resolve(worktreePath);
const worktreesDir = canonicalizePath(join(rootDir, ".worktrees"));
const target = canonicalizePath(worktreePath);
const rel = relative(worktreesDir, target);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}