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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user