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:
gsxdsm
2026-07-20 13:40:50 -07:00
parent 1b8b7f617e
commit 1d4e8afa7b
36 changed files with 384 additions and 36 deletions

View File

@@ -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>());

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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();
}
};