FN-8444: include planning time in task metrics
Track active planning time alongside execution time for costs, analytics, and task displays. - Persist planning timing state across task lifecycle transitions and recovery - Include planning activity in token cost, analytics, and dashboard timing displays - Add PostgreSQL migration support using the configured migration directory Files changed: .changeset/fn-8444-planning-time-cost.md | 7 +++ docs/dashboard-guide.md | 3 ++ docs/task-management.md | 5 ++ packages/core/src/index.ts | 1 + .../migrations/0029_planning_active_timing.sql | 3 ++ packages/core/src/postgres/schema-applier.ts | 14 ++++- packages/core/src/postgres/schema/project.ts | 2 + packages/core/src/productivity-analytics.ts | 29 +++++----- packages/core/src/store.ts | 2 +- .../core/src/task-store/archive-lifecycle-2.ts | 2 + packages/core/src/task-store/moves.ts | 7 +++ packages/core/src/task-store/persistence.ts | 4 ++ packages/core/src/task-store/remaining-ops-2.ts | 2 +- packages/core/src/task-store/serialization.ts | 7 +++ packages/core/src/task-store/task-row-mappers.ts | 2 +- packages/core/src/task-store/task-update.ts | 10 ++++ packages/core/src/task-timing.ts | 35 ++++++++++++ packages/core/src/types.ts | 12 +++++ packages/dashboard/app/components/TaskCard.tsx | 13 ++--- .../app/components/TaskTokenStatsPanel.tsx | 6 ++- .../app/components/__tests__/TaskCard.test.tsx | 17 ++++++ .../app/utils/__tests__/taskTiming.test.ts | 9 +++- packages/dashboard/app/utils/taskTiming.ts | 14 +++++ packages/dashboard/app/utils/taskTokenCost.ts | 2 + .../dashboard/src/task-planner-chat-metrics.ts | 14 ++++- packages/engine/src/__tests__/self-healing.test.ts | 61 +++++++++++++++++++++ packages/engine/src/executor.ts | 50 +++++++++++++++++ packages/engine/src/runtimes/in-process-runtime.ts | 3 ++ packages/engine/src/self-healing.ts | 62 ++++++++++++++++++++++ packages/engine/src/triage.ts | 10 ++++ packages/i18n/locales/en/app.json | 2 +- packages/i18n/locales/es/app.json | 2 +- packages/i18n/locales/fr/app.json | 2 +- packages/i18n/locales/ko/app.json | 2 +- packages/i18n/locales/zh-CN/app.json | 2 +- packages/i18n/locales/zh-TW/app.json | 2 +- 36 files changed, 384 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-8444 Fusion-Task-Lineage: 0178e0a7-3018-4ef4-be9b-6de5f964fb58 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -8235,6 +8235,67 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalizeOrphanedPlanningSegments", () => {
|
||||
it("finalizes an orphan exactly once and records an ids-only audit event", async () => {
|
||||
const task = {
|
||||
id: "FN-PLAN-1",
|
||||
planningStartedAt: "2026-01-01T00:00:00.000Z",
|
||||
cumulativePlanningMs: 50,
|
||||
} as Task;
|
||||
const updateTaskAtomic = vi.fn(async (_id: string, updater: (live: Task) => Partial<Task> | null) => {
|
||||
const patch = updater(task);
|
||||
if (patch) Object.assign(task, patch);
|
||||
return patch;
|
||||
});
|
||||
const recoveryStore = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
updateTaskAtomic,
|
||||
});
|
||||
const recovery = new SelfHealingManager(recoveryStore, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getPlanningTaskIds: () => new Set<string>(),
|
||||
hasActivePlanningWorkflowSession: () => false,
|
||||
});
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
|
||||
expect(await recovery.finalizeOrphanedPlanningSegments()).toBe(1);
|
||||
expect(updateTaskAtomic).toHaveBeenCalledOnce();
|
||||
expect(task).toMatchObject({ cumulativePlanningMs: 1050, planningStartedAt: null });
|
||||
expect(recoveryStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-orphaned-planning-segment",
|
||||
metadata: { taskId: "FN-PLAN-1", finalizedCount: 1, reason: "no-live-planning-owner" },
|
||||
}));
|
||||
expect(await recovery.finalizeOrphanedPlanningSegments()).toBe(0);
|
||||
expect(updateTaskAtomic).toHaveBeenCalledOnce();
|
||||
|
||||
recovery.stop();
|
||||
});
|
||||
|
||||
it("does not finalize a live graph Plan Review segment", async () => {
|
||||
const task = {
|
||||
id: "FN-PLAN-REVIEW",
|
||||
planningStartedAt: "2026-01-01T00:00:00.000Z",
|
||||
cumulativePlanningMs: 50,
|
||||
} as Task;
|
||||
const recoveryStore = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]) });
|
||||
const recovery = new SelfHealingManager(recoveryStore, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getPlanningTaskIds: () => new Set<string>(),
|
||||
hasActivePlanningWorkflowSession: (taskId) => taskId === "FN-PLAN-REVIEW",
|
||||
});
|
||||
|
||||
expect(await recovery.finalizeOrphanedPlanningSegments()).toBe(0);
|
||||
expect(recoveryStore.updateTask).not.toHaveBeenCalled();
|
||||
expect(recoveryStore.updateTaskAtomic).toBeUndefined();
|
||||
expect(recoveryStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:reconcile-orphaned-planning-segment-no-action",
|
||||
metadata: { finalizedCount: 0, reason: "no-eligible-orphan" },
|
||||
}));
|
||||
|
||||
recovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverOrphanedPlanningTasks", () => {
|
||||
it("clears status for orphaned planning tasks without a recoverable prompt", async () => {
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
@@ -96,6 +96,7 @@ import { Type, type Static } from "@earendil-works/pi-ai";
|
||||
import { describeModel, formatModelMarkerDetails, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
|
||||
import { accumulateSessionTokenUsage, captureSessionTokenBaseline, mergeTokenUsagePerModel, resetSessionTokenBaseline } from "./session-token-usage.js";
|
||||
import { finalizePlanningSegment, startPlanningSegment } from "@fusion/core";
|
||||
import { enforceTaskTokenBudgetForPersist } from "./token-budget-enforcer.js";
|
||||
import {
|
||||
createResolvedAgentSession,
|
||||
@@ -1730,6 +1731,12 @@ export class TaskExecutor {
|
||||
private effectiveColumnAgentByTask = new Map<string, string>();
|
||||
/** Active pre-merge workflow step sessions per task. */
|
||||
private activeWorkflowStepSessions = new Map<string, AgentSession>();
|
||||
/**
|
||||
* FNXC:TaskTiming 2026-08-01-12:00:
|
||||
* Only graph-owned Plan Review sessions appear here. Self-healing uses this
|
||||
* narrow liveness proof so it never finalizes an in-flight planning segment.
|
||||
*/
|
||||
private activePlanningWorkflowSessions = new Set<string>();
|
||||
/** Steering comments already observed for active workflow step sessions. */
|
||||
private activeWorkflowStepSessionSeenSteeringIds = new Map<string, Set<string>>();
|
||||
/** Active configured-command abort controllers keyed by task. */
|
||||
@@ -2459,6 +2466,17 @@ export class TaskExecutor {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:TaskTiming 2026-08-01-12:00:
|
||||
* A planning segment has one owner: a graph Plan Review session is live only
|
||||
* while both its session registration and planning ownership marker remain.
|
||||
* This is intentionally narrower than isTaskActive(), which also covers
|
||||
* implementation and non-planning workflow sessions.
|
||||
*/
|
||||
hasActivePlanningWorkflowSession(taskId: string): boolean {
|
||||
return this.activePlanningWorkflowSessions.has(taskId) && this.activeWorkflowStepSessions.has(taskId);
|
||||
}
|
||||
|
||||
isTaskActive(taskId: string): boolean {
|
||||
return (
|
||||
this.executing.has(taskId)
|
||||
@@ -16635,6 +16653,19 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
`Workflow step '${workflowStep.name}' using model: ${workflowModelDetails}`,
|
||||
);
|
||||
this.setActiveWorkflowStepSession(task.id, session, worktreePath, this.createSeenSteeringIds(task));
|
||||
// FNXC:TaskTiming 2026-08-01-10:00: graph-owned Plan Review is the only
|
||||
// post-spec planning lane. Start before prompting and finalize in finally before any replan handoff.
|
||||
const ownsPlanningSegment = workflowStep.id === "graph:plan-review-step" || workflowStep.name === "Plan Review";
|
||||
if (ownsPlanningSegment) {
|
||||
this.activePlanningWorkflowSessions.add(task.id);
|
||||
const planningStart = startPlanningSegment(task);
|
||||
try {
|
||||
if (planningStart.planningStartedAt) await this.store.updateTask(task.id, planningStart);
|
||||
} catch (error) {
|
||||
this.activePlanningWorkflowSessions.delete(task.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let output = "";
|
||||
const deltaNormalizer = createStreamingDeltaNormalizer();
|
||||
@@ -16717,6 +16748,9 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: timed out`);
|
||||
}
|
||||
// FNXC:TaskCost 2026-08-01-10:00: Plan Review tokens are task cost;
|
||||
// snapshot before timeout disposal just like normal completion.
|
||||
await accumulateSessionTokenUsage(this.store, task.id, session, { agentId: task.assignedAgentId ?? undefined, role: "executor" });
|
||||
try { session.dispose(); } catch { /* best-effort */ }
|
||||
await agentLogger.flush();
|
||||
return { success: false, error: `workflow step timed out after ${timeoutMs}ms`, timedOut: true };
|
||||
@@ -16772,6 +16806,9 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
return { success: true, output: parsed.output };
|
||||
} catch (err: unknown) {
|
||||
await agentLogger.flush();
|
||||
// Persist the delta before error disposal so graph-owned planning reviews
|
||||
// cannot disappear from operator cost totals.
|
||||
await accumulateSessionTokenUsage(this.store, task.id, session, { agentId: task.assignedAgentId ?? undefined, role: "executor" });
|
||||
try { session.dispose(); } catch { /* best-effort */ }
|
||||
if ((err instanceof ReadonlyViolationError) || ((err as { code?: string } | null)?.code === "READONLY_VIOLATION")) {
|
||||
const violation = err as ReadonlyViolationError;
|
||||
@@ -16792,6 +16829,19 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (ownsPlanningSegment) {
|
||||
try {
|
||||
const livePlanningTask = await this.store.getTask(task.id);
|
||||
if (livePlanningTask) {
|
||||
const planningEnd = finalizePlanningSegment(livePlanningTask);
|
||||
if (planningEnd.planningStartedAt === null) await this.store.updateTask(task.id, planningEnd);
|
||||
}
|
||||
} finally {
|
||||
// Finalize before releasing Plan Review ownership so triage can only
|
||||
// begin a subsequent, non-overlapping planning segment.
|
||||
this.activePlanningWorkflowSessions.delete(task.id);
|
||||
}
|
||||
}
|
||||
const activeWorkflowStepSession = this.activeWorkflowStepSessions.get(task.id);
|
||||
if (activeWorkflowStepSession === session) {
|
||||
this.deleteActiveWorkflowStepSession(task.id, worktreePath);
|
||||
|
||||
@@ -1050,6 +1050,9 @@ export class InProcessRuntime
|
||||
listWorktreeHolders: () => this.executor?.listWorktreeHolders() ?? [],
|
||||
recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false),
|
||||
getPlanningTaskIds: () => this.triageProcessor?.getPlanningTaskIds() ?? new Set<string>(),
|
||||
// FNXC:TaskTiming 2026-08-01-12:00: orphan planning recovery must defer
|
||||
// while executor-owned graph Plan Review holds the sole planning anchor.
|
||||
hasActivePlanningWorkflowSession: (taskId) => this.executor?.hasActivePlanningWorkflowSession(taskId) ?? false,
|
||||
reserveAdvancedTriageRecovery: (taskId) => this.triageProcessor?.tryReserveAdvancedRecovery(taskId),
|
||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||
enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, resolveReboundTarget, planLegacyAdoption, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core";
|
||||
import { finalizePlanningSegment } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
@@ -296,6 +297,8 @@ export interface SelfHealingOptions {
|
||||
* Used to avoid recovering active triage sessions.
|
||||
*/
|
||||
getPlanningTaskIds?: () => Set<string>;
|
||||
/** True only while the executor owns a graph Plan Review session for this task. */
|
||||
hasActivePlanningWorkflowSession?: (taskId: string) => boolean;
|
||||
/** Atomically fence planner ownership while advanced triage recovery runs. */
|
||||
reserveAdvancedTriageRecovery?: (taskId: string) => (() => void) | undefined;
|
||||
/**
|
||||
@@ -1405,6 +1408,7 @@ export class SelfHealingManager {
|
||||
{ name: "approved-triage", fn: () => this.recoverApprovedTriageTasks().then(() => undefined) },
|
||||
{ name: "recover-starved-refinement", fn: () => this.recoverStarvedRefinementTriageTasks().then(() => undefined) },
|
||||
{ name: "orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks().then(() => undefined) },
|
||||
{ name: "orphaned-planning-segments", fn: () => this.finalizeOrphanedPlanningSegments().then(() => undefined) },
|
||||
{ name: "reset-durable-agent-error-state-on-startup", fn: () => this.resetDurableAgentErrorStateOnStartup().then(() => undefined) },
|
||||
{ name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents().then(() => undefined) },
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
||||
@@ -2710,6 +2714,7 @@ export class SelfHealingManager {
|
||||
{ name: "resolve-explicit-duplicate-markers", fn: () => this.resolveExplicitDuplicateMarkerTasks() },
|
||||
{ name: "recover-starved-refinement", fn: () => this.recoverStarvedRefinementTriageTasks() },
|
||||
{ name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
|
||||
{ name: "finalize-orphaned-planning-segments", fn: () => this.finalizeOrphanedPlanningSegments() },
|
||||
{ name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() },
|
||||
{ name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents() },
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() },
|
||||
@@ -12245,6 +12250,63 @@ export class SelfHealingManager {
|
||||
* Recovery clears the status back to `null` so the next triage poll picks
|
||||
* them up for a fresh planning attempt.
|
||||
*/
|
||||
/**
|
||||
* FNXC:TaskTiming 2026-08-01-10:00:
|
||||
* A planning anchor is safe because triage ownership and graph Plan Review are
|
||||
* exclusive. Recovery finalizes only when neither in-process owner is live;
|
||||
* the atomic null-check makes restart and repeated maintenance idempotent.
|
||||
*/
|
||||
async finalizeOrphanedPlanningSegments(): Promise<number> {
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({});
|
||||
let finalized = 0;
|
||||
for (const task of tasks) {
|
||||
if (!task.planningStartedAt || planningIds.has(task.id) || this.options.hasActivePlanningWorkflowSession?.(task.id)) continue;
|
||||
let applied = false;
|
||||
const endMs = Date.now();
|
||||
if (typeof this.store.updateTaskAtomic === "function") {
|
||||
await this.store.updateTaskAtomic(task.id, (live) => {
|
||||
if (!live.planningStartedAt || planningIds.has(live.id) || this.options.hasActivePlanningWorkflowSession?.(live.id)) return null;
|
||||
const patch = finalizePlanningSegment(live, endMs);
|
||||
applied = patch.planningStartedAt === null;
|
||||
return patch;
|
||||
});
|
||||
} else {
|
||||
const live = await this.store.getTask(task.id);
|
||||
if (live?.planningStartedAt && !planningIds.has(live.id) && !this.options.hasActivePlanningWorkflowSession?.(live.id)) {
|
||||
const patch = finalizePlanningSegment(live, endMs);
|
||||
if (patch.planningStartedAt === null) { await this.store.updateTask(task.id, patch); applied = true; }
|
||||
}
|
||||
}
|
||||
if (applied) {
|
||||
finalized++;
|
||||
// FNXC:TaskTiming 2026-08-01-12:00: this recovery is operator-auditable
|
||||
// without persisting duration prose; the atomically finalized task id
|
||||
// and fixed no-live-owner reason are sufficient forensic evidence.
|
||||
await this.store.recordRunAuditEvent?.({
|
||||
taskId: task.id,
|
||||
agentId: "self-healing",
|
||||
runId: generateSyntheticRunId("orphaned-planning-segment", task.id),
|
||||
domain: "database",
|
||||
mutationType: "task:reconcile-orphaned-planning-segment",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, finalizedCount: 1, reason: "no-live-planning-owner" },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (finalized === 0) {
|
||||
await this.store.recordRunAuditEvent?.({
|
||||
agentId: "self-healing",
|
||||
runId: generateSyntheticRunId("orphaned-planning-segment", "global"),
|
||||
domain: "database",
|
||||
mutationType: "task:reconcile-orphaned-planning-segment-no-action",
|
||||
target: "planning-segments",
|
||||
metadata: { finalizedCount: 0, reason: "no-eligible-orphan" },
|
||||
});
|
||||
}
|
||||
return finalized;
|
||||
}
|
||||
|
||||
async recoverOrphanedPlanningTasks(): Promise<number> {
|
||||
try {
|
||||
// Evict stale entries from the triage processor's in-memory set before
|
||||
|
||||
@@ -178,6 +178,7 @@ import { archiveAsGhostBug } from "./self-healing.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
|
||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { finalizePlanningSegment, startPlanningSegment } from "@fusion/core";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
|
||||
|
||||
@@ -1574,6 +1575,10 @@ export class TriageProcessor {
|
||||
"triage",
|
||||
);
|
||||
|
||||
// FNXC:TaskTiming 2026-08-01-10:00: triage owns the initial planning lane;
|
||||
// first-start wins so a crash between ownership and persistence cannot open a second segment.
|
||||
const planningStart = startPlanningSegment(task);
|
||||
if (planningStart.planningStartedAt) await this.store.updateTask(task.id, planningStart);
|
||||
// Register session so the global pause listener can terminate it
|
||||
this.activeSessions.set(task.id, session);
|
||||
|
||||
@@ -1827,6 +1832,11 @@ export class TriageProcessor {
|
||||
Every triage planning exit path, including APPROVE, retry, pause/stuck abort, split/delete, and rate-limit wrapper attempts, records the active session's actual model before disposal so by-model analytics do not collapse triage usage to missing buckets.
|
||||
*/
|
||||
await this.recordTriageSessionTokenUsage(task.id, session, { agentId: triageRunContext.agentId });
|
||||
const livePlanningTask = await this.store.getTask(task.id);
|
||||
if (livePlanningTask) {
|
||||
const planningEnd = finalizePlanningSegment(livePlanningTask);
|
||||
if (planningEnd.planningStartedAt === null) await this.store.updateTask(task.id, planningEnd);
|
||||
}
|
||||
session.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user