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

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