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"); 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[] = [ const historicalEntries: AgentLogEntry[] = [
{ {
timestamp: "2026-03-31T10:03:00Z", timestamp: "2026-03-31T10:03:00Z",
@@ -170,10 +170,39 @@ describe("WorkflowResultsTab", () => {
<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />, <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(); 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", () => { it("shows output content when toggle is clicked to expand", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />); render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);

View File

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

View File

@@ -1,6 +1,6 @@
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { promisify } from "node:util"; 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 { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core"; import type { Column, TaskStore } from "@fusion/core";
import { inspectBranchConflict } from "./branch-conflicts.js"; import { inspectBranchConflict } from "./branch-conflicts.js";
@@ -8,6 +8,14 @@ import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
function canonicalizePath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
function getExecStdout(result: unknown): string { function getExecStdout(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
if (result && typeof result === "object" && "stdout" in 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>(); const paths = new Set<string>();
for (const line of stdout.split("\n")) { for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) { if (line.startsWith("worktree ")) {
paths.add(resolve(line.slice("worktree ".length))); paths.add(canonicalizePath(line.slice("worktree ".length)));
} }
} }
return paths; 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> { 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 { 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 { export function isInsideWorktreesDir(rootDir: string, worktreePath: string): boolean {
const worktreesDir = resolve(rootDir, ".worktrees"); const worktreesDir = canonicalizePath(join(rootDir, ".worktrees"));
const target = resolve(worktreePath); const target = canonicalizePath(worktreePath);
const rel = relative(worktreesDir, target); const rel = relative(worktreesDir, target);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
} }