diff --git a/.changeset/fn-8444-planning-time-cost.md b/.changeset/fn-8444-planning-time-cost.md new file mode 100644 index 0000000000..ec3b1cb8cd --- /dev/null +++ b/.changeset/fn-8444-planning-time-cost.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Include planning-lane AI time and tokens in task cost and duration totals. +category: fix +dev: Adds cumulativePlanningMs and planningStartedAt; Stats now says Total active time. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 056499cd6f..758eef3e78 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2155,3 +2155,6 @@ The uploaded image uses the tracking repository's raw URL. It renders inline for ## Chat-requested task verification Chat can queue `fn_task_request_verification` for an **in-progress** task that has a live executor worktree. The only profiles are `verify:fast` (default) and the project-configured `test-command`; chat never accepts or executes raw shell text. Command-execution policy applies to the request, including approval and denial outcomes. Use `fn_task_verification_status` to read the persisted request, running state, or bounded terminal output. The executor owns the actual run and shared verification concurrency slot, so results remain visible through task execution state and Command Center observability. + + +Productivity duration uses total agent-active time: planning (`cumulativePlanningMs`) plus execution (`cumulativeActiveMs`); queued column dwell is not included. diff --git a/docs/task-management.md b/docs/task-management.md index dab2219f3f..53ec54b5c3 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -943,3 +943,8 @@ Use `noCommitsExpected: true` for tasks where the deliverable is a decision/repo - You can manually set/clear it in Task Detail via **No commits expected (decision-only task)**. - Task cards show a **decision-only** badge when enabled. - Finalization still uses the existing no-op review/merge path (`mergeDetails.noOpMerge: true`, `mergeConfirmed: true`); no synthetic merge strategy values are introduced. + + +### Active-time statistics + +Task Detail labels this measure **Total active time**. It sums durable planning AI time (`cumulativePlanningMs`, including a live `planningStartedAt` segment) and in-progress execution time (`cumulativeActiveMs`, including a live `executionStartedAt` segment). Column dwell is wall-clock queue time and is intentionally excluded. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df0dc4c4f2..48b474bde6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2603,3 +2603,4 @@ export { export type { LanguageFamily, DetectedContentLanguage } from "./detect-content-language.js"; export { promoteResearchFinding } from "./research-feature-promotion.js"; export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js"; +export { getTotalAgentActiveMs, startPlanningSegment, finalizePlanningSegment } from "./task-timing.js"; diff --git a/packages/core/src/postgres/migrations/0029_planning_active_timing.sql b/packages/core/src/postgres/migrations/0029_planning_active_timing.sql new file mode 100644 index 0000000000..ce2ef0698e --- /dev/null +++ b/packages/core/src/postgres/migrations/0029_planning_active_timing.sql @@ -0,0 +1,3 @@ +-- FNXC:TaskTiming 2026-08-01-10:00: durable planning AI session accounting. +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS cumulative_planning_ms bigint; +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS planning_started_at text; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 1705a957b6..86ec1d665c 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -37,7 +37,7 @@ FNXC:PostgresBigintCounters 2026-07-19-12:00: SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0028"; +export const SCHEMA_BASELINE_VERSION = "0029"; /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -124,6 +124,8 @@ export const TASK_VERIFICATION_REQUEST_VERSION = "0024"; export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025"; /** FNXC:PostgresBigintCounters 2026-07-18-21:45: widen overflow-prone counters to bigint before SQLite migration. */ export const BIGINT_COUNTERS_VERSION = "0026"; +/** FNXC:TaskTiming 2026-08-01-10:00: existing clusters need planning-session timing columns. */ +export const PLANNING_ACTIVE_TIMING_VERSION = "0029"; /** * Thrown when the database was migrated by a NEWER Fusion binary than the one now @@ -312,6 +314,8 @@ const WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH = join( MIGRATIONS_DIR, "0027_workflow_ir_pin_and_legacy_adoption.sql", ); + +const PLANNING_ACTIVE_TIMING_MIGRATION_PATH = join(MIGRATIONS_DIR, "0029_planning_active_timing.sql"); const TASK_DECLARED_SYMBOLS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0028_task_declared_symbols.sql"); /** @@ -409,6 +413,7 @@ export async function applySchemaBaseline( const symbolLocksAlreadyApplied = applied.includes(SYMBOL_LOCKS_SCHEMA_VERSION); const bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION); const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION); + const planningActiveTimingAlreadyApplied = applied.includes(PLANNING_ACTIVE_TIMING_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -830,6 +835,13 @@ export async function applySchemaBaseline( baseline so databases that already recorded 0000 gain the IR-pin and adoption columns; without the forward migration those clusters crash on the first slim TaskStore SELECT. */ + if (!planningActiveTimingAlreadyApplied) { + const migrationSql = await readFile(PLANNING_ACTIVE_TIMING_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PLANNING_ACTIVE_TIMING_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + if (!workflowIrPinAndLegacyAdoptionAlreadyApplied) { const migrationSql = await readFile(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH, "utf8"); await tx.execute(sql.raw(migrationSql)); diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 66f112843f..81c9b1ff7f 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -178,6 +178,8 @@ export const tasks = projectSchema.table("tasks", { columnMovedAt: text("column_moved_at"), firstExecutionAt: text("first_execution_at"), cumulativeActiveMs: bigint("cumulative_active_ms", { mode: "number" }), + cumulativePlanningMs: bigint("cumulative_planning_ms", { mode: "number" }), + planningStartedAt: text("planning_started_at"), /* FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: Keep the task schema aligned with late SQLite lifecycle migrations. JSON lifecycle markers stay jsonb for native backend reads; retired board/question fields remain text so their legacy payloads round-trip byte-for-byte. diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index 051a3aebf5..01b72c28c9 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -64,7 +64,7 @@ export interface HoursSavedSummary { /** * FNXC:CommandCenterProductivity 2026-06-19-12:00: - * Task-duration productivity stats are derived from `tasks.cumulativeActiveMs` for done tasks completed in the selected range. Missing qualifying durations are unavailable, not zero, so old or untracked tasks do not read as instant work. + * Task-duration productivity stats are derived from combined planning (`cumulativePlanningMs`) and execution (`cumulativeActiveMs`) activity for done tasks completed in the selected range. Missing qualifying combined durations are unavailable, not zero, so old or untracked tasks do not read as instant work. */ export interface TaskDurationSummary { completedTasks: number; @@ -77,7 +77,7 @@ export interface TaskDurationSummary { /** * FNXC:CommandCenterProductivity 2026-06-30-10:17: - * Operators need average and median task active duration over time from real completed-task `cumulativeActiveMs` history. Trend buckets are emitted only for days with qualifying completed tasks; missing history must stay absent/unavailable, never fabricated as zero-duration chart points. + * Operators need average and median task active duration over time from real completed-task planning plus execution history. Trend buckets are emitted only for days with qualifying completed tasks; missing history must stay absent/unavailable, never fabricated as zero-duration chart points. */ export interface TaskDurationTrendBucket { bucket: string; @@ -124,7 +124,8 @@ interface ModifiedFilesRow { } interface TaskDurationRow { - cumulativeActiveMs: number; + cumulativeActiveMs: number | null; + cumulativePlanningMs: number | null; executionCompletedAt: string; } @@ -250,8 +251,7 @@ export async function aggregateProductivityAnalytics( const durationClauses: string[] = [ `"column" = 'done'`, "executionCompletedAt IS NOT NULL", - "cumulativeActiveMs IS NOT NULL", - "cumulativeActiveMs > 0", + "(COALESCE(cumulativeActiveMs, 0) + COALESCE(cumulativePlanningMs, 0)) > 0", ]; const durationParams: string[] = []; if (query.from !== undefined) { @@ -264,10 +264,10 @@ export async function aggregateProductivityAnalytics( } const durationRows = db .prepare( - `SELECT cumulativeActiveMs, executionCompletedAt FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY executionCompletedAt ASC`, + `SELECT cumulativeActiveMs, cumulativePlanningMs, executionCompletedAt FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY executionCompletedAt ASC`, ) .all(...durationParams) as TaskDurationRow[]; - const durations = durationRows.map((row) => row.cumulativeActiveMs).sort((a, b) => a - b); + const durations = durationRows.map((row) => (row.cumulativeActiveMs ?? 0) + (row.cumulativePlanningMs ?? 0)).sort((a, b) => a - b); const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0); const taskDuration: TaskDurationSummary = durations.length > 0 ? { @@ -291,7 +291,7 @@ export async function aggregateProductivityAnalytics( for (const row of durationRows) { const bucket = row.executionCompletedAt.slice(0, 10); const bucketDurations = durationBuckets.get(bucket) ?? []; - bucketDurations.push(row.cumulativeActiveMs); + bucketDurations.push((row.cumulativeActiveMs ?? 0) + (row.cumulativePlanningMs ?? 0)); durationBuckets.set(bucket, bucketDurations); } const taskDurationTrend: TaskDurationTrendBucket[] = [...durationBuckets.entries()].map(([bucket, bucketDurations]) => { @@ -401,16 +401,15 @@ async function aggregateProductivityAnalyticsAsync( const dFrom = query.from !== undefined ? sql`AND execution_completed_at >= ${query.from}` : sql``; const dTo = query.to !== undefined ? sql`AND execution_completed_at <= ${query.to}` : sql``; const durationRows = (await layer.db.execute( - sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", execution_completed_at AS "executionCompletedAt" + sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt" FROM project.tasks WHERE "column" = 'done' AND execution_completed_at IS NOT NULL - AND cumulative_active_ms IS NOT NULL - AND cumulative_active_ms > 0 + AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0 ${dFrom} ${dTo} - ORDER BY cumulative_active_ms ASC`, - )) as Array<{ cumulativeActiveMs: number; executionCompletedAt: string }>; - const durations = durationRows.map((row) => Number(row.cumulativeActiveMs)); + ORDER BY (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) ASC`, + )) as Array<{ cumulativeActiveMs: number | null; cumulativePlanningMs: number | null; executionCompletedAt: string }>; + const durations = durationRows.map((row) => Number(row.cumulativeActiveMs ?? 0) + Number(row.cumulativePlanningMs ?? 0)); const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0); const taskDuration: TaskDurationSummary = durations.length > 0 ? { @@ -434,7 +433,7 @@ async function aggregateProductivityAnalyticsAsync( for (const row of durationRows) { const bucket = row.executionCompletedAt.slice(0, 10); const bucketDurations = durationBuckets.get(bucket) ?? []; - bucketDurations.push(Number(row.cumulativeActiveMs)); + bucketDurations.push(Number(row.cumulativeActiveMs ?? 0) + Number(row.cumulativePlanningMs ?? 0)); durationBuckets.set(bucket, bucketDurations); } const taskDurationTrend: TaskDurationTrendBucket[] = [...durationBuckets.entries()].map(([bucket, bucketDurations]) => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index ba53e74df6..3f2d2f49ad 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1240,7 +1240,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { return updateTaskImpl(this, id, updates, runContext); } diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index f5637575e1..33ed0ace01 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -70,6 +70,8 @@ export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archi columnMovedAt: task.columnMovedAt, firstExecutionAt: task.firstExecutionAt, cumulativeActiveMs: task.cumulativeActiveMs, + cumulativePlanningMs: task.cumulativePlanningMs, + planningStartedAt: task.planningStartedAt, executionStartedAt: task.executionStartedAt, executionCompletedAt: task.executionCompletedAt, archivedAt, diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index 8c95314bbf..b776052917 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -691,6 +691,13 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum }); const movedAt = internal.now ?? new Date().toISOString(); + // FNXC:TaskTiming 2026-08-01-10:00: column dwell is wall-clock stage data, + // accumulated before replacing the prior column anchor and never used as AI active time. + const priorColumnMovedAt = Date.parse(task.columnMovedAt ?? ""); + const moveMs = Date.parse(movedAt); + if (fromColumn !== toColumn && Number.isFinite(priorColumnMovedAt) && Number.isFinite(moveMs)) { + task.columnDwellMs = { ...(task.columnDwellMs ?? {}), [fromColumn]: Math.max(0, task.columnDwellMs?.[fromColumn] ?? 0) + Math.max(0, moveMs - priorColumnMovedAt) }; + } task.column = toColumn; task.columnMovedAt = movedAt; task.updatedAt = movedAt; diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 2e3f01616b..02310a7220 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -106,6 +106,8 @@ export interface TaskRow { columnMovedAt: string | null; firstExecutionAt: string | null; cumulativeActiveMs: number | null; + cumulativePlanningMs: number | null; + planningStartedAt: string | null; columnDwellMs: string | null; workflowTransitionNotification: string | null; plannerOversightLevel: string | null; @@ -307,6 +309,8 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("columnMovedAt", (task) => task.columnMovedAt ?? null), defineTaskColumn("firstExecutionAt", (task) => task.firstExecutionAt ?? null), defineTaskColumn("cumulativeActiveMs", (task) => task.cumulativeActiveMs ?? null), + defineTaskColumn("cumulativePlanningMs", (task) => task.cumulativePlanningMs ?? null), + defineTaskColumn("planningStartedAt", (task) => task.planningStartedAt ?? null), /* FNXC:TaskLifecyclePersistence 2026-07-14-13:17: Persist the late task lifecycle fields through the shared descriptor seam so both SQLite and PostgreSQL retain per-column timing, workflow transition dedupe, oversight overrides, and manual-plan approval state after migration. diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index 3e960ba7b3..9bfef1dde0 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -58,7 +58,7 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li "workflowIrPin", "workflowIrPinNodeId", "workflowIrPinColumnId", "legacyAdoptedAt", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "mergerThinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "cumulativePlanningMs", "planningStartedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "declaredSymbols", diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 388fd62dfb..de277c4b4b 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -149,6 +149,8 @@ export function rowToTask(row: TaskRow): Task { columnMovedAt: row.columnMovedAt || undefined, firstExecutionAt: row.firstExecutionAt || undefined, cumulativeActiveMs: row.cumulativeActiveMs ?? undefined, + cumulativePlanningMs: row.cumulativePlanningMs ?? undefined, + planningStartedAt: row.planningStartedAt || undefined, columnDwellMs: fromJson>(row.columnDwellMs) ?? undefined, workflowTransitionNotification: fromJson(row.workflowTransitionNotification) ?? undefined, plannerOversightLevel: (row.plannerOversightLevel || undefined) as Task["plannerOversightLevel"], @@ -362,6 +364,11 @@ export function archiveEntryToTask( columnMovedAt: entry.columnMovedAt, firstExecutionAt: entry.firstExecutionAt, cumulativeActiveMs: entry.cumulativeActiveMs, + // FNXC:TaskTiming 2026-08-01-13:00: archive/restore must retain both + // planning fields so archived tasks neither lose accumulated AI time nor + // revive without the live segment anchor needed for exactly-once finalize. + cumulativePlanningMs: entry.cumulativePlanningMs, + planningStartedAt: entry.planningStartedAt, executionStartedAt: entry.executionStartedAt, executionCompletedAt: entry.executionCompletedAt, modelPresetId: entry.modelPresetId, diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index 67e1d08fcf..87e420fbcc 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -40,7 +40,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "mergerThinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "cumulativePlanningMs", "planningStartedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "declaredSymbols", diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 9da3b51b7a..ca266fc0db 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -604,6 +604,16 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.cumulativeActiveMs !== undefined) { task.cumulativeActiveMs = updates.cumulativeActiveMs; } + if (updates.cumulativePlanningMs === null) { + task.cumulativePlanningMs = undefined; + } else if (updates.cumulativePlanningMs !== undefined) { + task.cumulativePlanningMs = updates.cumulativePlanningMs; + } + if (updates.planningStartedAt === null) { + task.planningStartedAt = undefined; + } else if (updates.planningStartedAt !== undefined) { + task.planningStartedAt = updates.planningStartedAt; + } if (updates.executionStartedAt === null) { task.executionStartedAt = undefined; } else if (updates.executionStartedAt !== undefined) { diff --git a/packages/core/src/task-timing.ts b/packages/core/src/task-timing.ts new file mode 100644 index 0000000000..4892aedfc7 --- /dev/null +++ b/packages/core/src/task-timing.ts @@ -0,0 +1,35 @@ +import type { Task } from "./types.js"; + +/** + * FNXC:TaskTiming 2026-08-01-10:00: + * Operators' active-time totals include live and persisted planning AI work as + * well as in-progress execution. Column dwell remains idle wall-clock data and + * must never be substituted for an agent session anchor. + */ +export function getTotalAgentActiveMs( + task: Pick, + nowMs: number, +): number | null { + const executionBase = Math.max(0, task.cumulativeActiveMs ?? 0); + const executionStartMs = task.column === "in-progress" ? Date.parse(task.executionStartedAt ?? "") : NaN; + const execution = executionBase + (Number.isFinite(executionStartMs) ? Math.max(0, nowMs - executionStartMs) : 0); + const planningBase = Math.max(0, task.cumulativePlanningMs ?? 0); + const planningStartMs = Date.parse(task.planningStartedAt ?? ""); + const planning = planningBase + (Number.isFinite(planningStartMs) ? Math.max(0, nowMs - planningStartMs) : 0); + return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || Number.isFinite(executionStartMs) || Number.isFinite(planningStartMs) + ? execution + planning + : null; +} + +export function startPlanningSegment>(task: T, nowMs = Date.now()): { planningStartedAt?: string } { + return task.planningStartedAt ? {} : { planningStartedAt: new Date(nowMs).toISOString() }; +} + +export function finalizePlanningSegment>(task: T, endMs = Date.now()): { cumulativePlanningMs?: number; planningStartedAt?: null } { + const startedMs = Date.parse(task.planningStartedAt ?? ""); + if (!Number.isFinite(startedMs)) return {}; + return { + cumulativePlanningMs: Math.max(0, task.cumulativePlanningMs ?? 0) + Math.max(0, endMs - startedMs), + planningStartedAt: null, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4883441ae0..cec418019a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2078,6 +2078,14 @@ export interface Task { * Incremented whenever the task leaves `in-progress`; never decremented and * never cleared by reopen flows. */ cumulativeActiveMs?: number; + /** + * FNXC:TaskTiming 2026-08-01-10:00: + * Monotonic active AI planning duration. Unlike column dwell this is only + * accrued by a live planning session and is never cleared by reopen. + */ + cumulativePlanningMs?: number; + /** Open planning AI segment; finalized exactly once into cumulativePlanningMs. */ + planningStartedAt?: string; /* FNXC:TaskTiming 2026-06-26-10:14: Per-stage dwell-time instrumentation. `cumulativeActiveMs` only measures `in-progress`, @@ -4908,6 +4916,10 @@ export interface ArchivedTaskEntry { firstExecutionAt?: string; /** Accumulated active runtime spent in `in-progress` across attempts. */ cumulativeActiveMs?: number; + /** Accumulated active AI planning duration carried through archive/restore. */ + cumulativePlanningMs?: number; + /** Open planning AI segment carried through archive/restore. */ + planningStartedAt?: string; /** FNXC:TaskTiming 2026-06-26-10:14: per-column cumulative dwell (ms) carried through * archive/restore so per-stage wall-clock survives archival. See Task.columnDwellMs. */ columnDwellMs?: Record; diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 62fa81c577..2ea1c0cb4e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -42,7 +42,7 @@ import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../uti import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; -import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; +import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; @@ -357,10 +357,11 @@ function getInProgressElapsedMs(task: Task, nowMs: number): number | null { // inside instrumented code paths. Returns null on legacy tasks that completed // before `executionStartedAt` was tracked, so callers can fall back. function getTaskEndToEndDurationMs(task: Task, nowMs: number): number | null { - if (task.cumulativeActiveMs == null) { - return getEndToEndDurationMs(task.executionStartedAt, task.executionCompletedAt, nowMs); - } - return getActiveRuntimeMs(task, nowMs); + // FNXC:TaskTiming 2026-08-01-12:00: planning-only tasks have no execution + // accumulator, but their active AI duration still belongs on the card chip. + // Use the legacy execution window only when neither active-time source exists. + const totalActiveMs = getTotalAgentActiveMs(task, nowMs); + return totalActiveMs ?? getEndToEndDurationMs(task.executionStartedAt, task.executionCompletedAt, nowMs); } function getInReviewCompletionMs(task: Task): number | null { @@ -1585,7 +1586,7 @@ function TaskCardComponent({ title: t("tasks.executionTimeCompleted", "Execution time {{elapsed}}. Completed {{completedAt}}", { elapsed: elapsedLabel, completedAt }), ariaLabel: t("tasks.executionTimeCompleted", "Execution time {{elapsed}}. Completed {{completedAt}}", { elapsed: elapsedLabel, completedAt }), }; - }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]); + }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.cumulativePlanningMs, task.planningStartedAt, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]); const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`); diff --git a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx index d033530f9a..d2b25ccb64 100644 --- a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx +++ b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import type { Task, TaskTokenUsage, WorkflowStepResult } from "@fusion/core"; -import { extractTimingEvents, getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWallClockSinceFirstExecutionMs, getWorkflowRuntimeMs, type TimingEvent } from "../utils/taskTiming"; +import { extractTimingEvents, getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWallClockSinceFirstExecutionMs, getWorkflowRuntimeMs, type TimingEvent } from "../utils/taskTiming"; import { getCanonicalStepNumber } from "../lib/step-display"; import "./TaskTokenStatsPanel.css"; @@ -32,6 +32,8 @@ interface TaskTokenStatsPanelProps { | "executionCompletedAt" | "firstExecutionAt" | "cumulativeActiveMs" + | "cumulativePlanningMs" + | "planningStartedAt" | "column" | "columnMovedAt" >; @@ -133,7 +135,7 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat }, undefined); const workflowTiming = summarizeWorkflowTiming(task?.workflowStepResults ?? []); - const activeRuntimeMs = task ? getActiveRuntimeMs(task, nowMs) : null; + const activeRuntimeMs = task ? getTotalAgentActiveMs(task, nowMs) : null; const endToEndDurationMs = getEndToEndDurationMs(task?.executionStartedAt, task?.executionCompletedAt, nowMs); const wallClockSinceFirstExecutionMs = getWallClockSinceFirstExecutionMs( task?.firstExecutionAt, diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 18c1dfe1bb..a05f2e5f9f 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -5824,6 +5824,23 @@ describe("TaskCard", () => { expect(timer?.getAttribute("title")).toBe("Execution time 6m"); }); + it("renders planning-only active duration when execution timing is absent", () => { + const { container } = render( + , + ); + + const timer = container.querySelector(".card-time-indicator"); + expect(timer?.textContent).toContain("6m"); + }); + it("keeps legacy wall-clock timers after firstExecutionAt migration backfill", () => { const { container } = render( { it("returns persisted plus live segment for in-progress tasks", () => { @@ -17,6 +17,13 @@ describe("taskTiming helpers", () => { expect(runtime).toBe(300_000); }); + it("sums planning and execution segments without using idle dwell", () => { + expect(getTotalAgentActiveMs({ + column: "done", cumulativeActiveMs: 120_000, executionStartedAt: undefined, + cumulativePlanningMs: 180_000, planningStartedAt: undefined, + }, Date.parse("2026-05-15T13:16:00.000Z"))).toBe(300_000); + }); + it("returns null when there is no active-runtime signal", () => { const runtime = getActiveRuntimeMs( { diff --git a/packages/dashboard/app/utils/taskTiming.ts b/packages/dashboard/app/utils/taskTiming.ts index f0d35ed8f2..997144e7a9 100644 --- a/packages/dashboard/app/utils/taskTiming.ts +++ b/packages/dashboard/app/utils/taskTiming.ts @@ -114,6 +114,20 @@ export function getActiveRuntimeMs( return null; } +/** FNXC:TaskTiming 2026-08-01-10:00: rendered task totals include planning AI + * segments while getActiveRuntimeMs intentionally remains execution-only. */ +export function getTotalAgentActiveMs( + task: Pick, + nowMs: number, +): number | null { + const execution = getActiveRuntimeMs(task, nowMs) ?? 0; + const planningStart = parseTimestampToMs(task.planningStartedAt); + const planning = Math.max(0, task.cumulativePlanningMs ?? 0) + (planningStart != null ? Math.max(0, nowMs - planningStart) : 0); + return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || (task.column === "in-progress" && parseTimestampToMs(task.executionStartedAt) != null) || planningStart != null + ? execution + planning + : null; +} + export function getWallClockSinceFirstExecutionMs( firstExecutionAt: string | undefined, executionCompletedAt: string | undefined, diff --git a/packages/dashboard/app/utils/taskTokenCost.ts b/packages/dashboard/app/utils/taskTokenCost.ts index 31f6faa75a..3303f6c071 100644 --- a/packages/dashboard/app/utils/taskTokenCost.ts +++ b/packages/dashboard/app/utils/taskTokenCost.ts @@ -67,6 +67,8 @@ export function toTokenCostRow( * Task cost is a read-time derivation shared by the done Summary tab, always-available Cost tab, and optional card badge. Keep the costFor/pricing-overrides path centralized here so unpriced or zero-usage states keep the guess-free “—” sentinel everywhere and derived USD is never persisted. * * FNXC:TaskDetailSummaryTokenCost 2026-06-27-00:00: + * FNXC:TaskCost 2026-08-01-10:00: tokenUsage is a full-task additive ledger; + * triage and graph Plan Review buckets intentionally have no role filter here. * Done-task Summary shows durable token usage broken down by model with derived USD cost. Use already-loaded task.tokenUsage.perModel buckets plus costFor and global pricing overrides threaded from TaskDetailModal; do not fetch or persist cost here. Unpriced models render “—” instead of $0 and make the task total unavailable so estimates are never understated. */ export function buildTokenCostRows(task: TaskDetail, unknownLabel: string, pricingOverrides?: ModelPricingOverrides): TokenCostRow[] { diff --git a/packages/dashboard/src/task-planner-chat-metrics.ts b/packages/dashboard/src/task-planner-chat-metrics.ts index 42c56300ea..a74cfe54fd 100644 --- a/packages/dashboard/src/task-planner-chat-metrics.ts +++ b/packages/dashboard/src/task-planner-chat-metrics.ts @@ -15,6 +15,8 @@ type MetricsTask = Pick< | "executionCompletedAt" | "firstExecutionAt" | "cumulativeActiveMs" + | "cumulativePlanningMs" + | "planningStartedAt" >; type TokenBucketInput = Pick< @@ -75,6 +77,7 @@ export interface TaskPlannerChatMetricsPayload { wallClockSinceFirstExecutionMs: number | null; activeRuntimeMs: number | null; cumulativeActiveMs: number | null; + cumulativePlanningMs: number | null; timedExecutionMs: number | null; logTimingDurationMs: number | null; timingEventCount: number; @@ -361,6 +364,12 @@ function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMe ? (cumulativeActiveMs ?? 0) + Math.max(0, nowMs - executionStartedMs) : cumulativeActiveMs; + const cumulativePlanningMs = optionalFiniteNumber(task.cumulativePlanningMs); + const planningStartedMs = parseTimestampToMs(task.planningStartedAt, malformedTimestamps); + const totalActiveMs = (activeRuntimeMs != null || cumulativePlanningMs != null || planningStartedMs != null) + ? (activeRuntimeMs ?? 0) + (cumulativePlanningMs ?? 0) + (planningStartedMs != null ? Math.max(0, nowMs - planningStartedMs) : 0) + : null; + const timingEvents = extractTimingEvents(task.log); const timedEvents = timingEvents.filter((event) => event.durationMs != null); const logTimingDurationMs = timedEvents.length > 0 @@ -380,7 +389,7 @@ function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMe if (!longest || (step.durationMs ?? 0) > (longest.durationMs ?? 0)) return step; return longest; }, null); - const totalExecutionMs = activeRuntimeMs + const totalExecutionMs = totalActiveMs ?? endToEndExecutionMs ?? timedExecutionMs ?? (logTimingDurationMs != null || workflowRuntimeMs != null ? (logTimingDurationMs ?? 0) + (workflowRuntimeMs ?? 0) : null); @@ -393,6 +402,7 @@ function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMe wallClockSinceFirstExecutionMs, activeRuntimeMs, cumulativeActiveMs, + cumulativePlanningMs, timedExecutionMs, logTimingDurationMs, timingEventCount: timingEvents.length, @@ -436,7 +446,7 @@ export function formatTaskPlannerChatMetrics( ? "cost unavailable because at least one model has no pricing" : `estimated cost ${formatUsd(metrics.tokens.cost.usd)}`; const staleSuffix = metrics.tokens.cost.pricingStale ? "; pricing is stale" : ""; - const timingSummary = `total execution ${formatDuration(metrics.timing.totalExecutionMs)}, active runtime ${formatDuration(metrics.timing.activeRuntimeMs)}, ${metrics.timing.timingEventCount.toLocaleString()} timing events, ${metrics.timing.timedWorkflowStepCount.toLocaleString()} workflow steps with timing`; + const timingSummary = `total active ${formatDuration(metrics.timing.totalExecutionMs)}, execution runtime ${formatDuration(metrics.timing.activeRuntimeMs)}, ${metrics.timing.timingEventCount.toLocaleString()} timing events, ${metrics.timing.timedWorkflowStepCount.toLocaleString()} workflow steps with timing`; return { metrics, diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index f08c3cf31c..856f77ccea 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -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 | 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(), + 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(), + 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()); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 28c52f3b8e..a0d7f7d38a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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(); /** Active pre-merge workflow step sessions per task. */ private activeWorkflowStepSessions = new Map(); + /** + * 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(); /** Steering comments already observed for active workflow step sessions. */ private activeWorkflowStepSessionSeenSteeringIds = new Map>(); /** 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); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 9207b76024..9f7b1a88e4 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -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(), + // 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(), enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined, diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 7b3db0b9e6..0887843121 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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; + /** 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 { + const planningIds = this.options.getPlanningTaskIds?.() ?? new Set(); + 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 { try { // Evict stale entries from the triage processor's in-memory set before diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index daf013c619..e485650df1 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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(); } }; diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 2dc74c374d..142aedb27d 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8020,7 +8020,7 @@ }, "tokenTotalsAria": "Task token totals", "tokenUsage": "Token Usage", - "totalExecutionTime": "Total execution time", + "totalExecutionTime": "Total active time", "totalTokens": "Total", "updateFailed": "Failed to update {{id}}: {{error}}", "updateSuccess": "Updated {{id}}", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 52d34d5159..1562cf34d7 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -7955,7 +7955,7 @@ }, "tokenTotalsAria": "Totales de tokens de la tarea", "tokenUsage": "Uso de tokens", - "totalExecutionTime": "Tiempo total de ejecución", + "totalExecutionTime": "Tiempo activo total", "totalTokens": "Total", "updateFailed": "Error al actualizar {{id}}: {{error}}", "updateSuccess": "{{id}} actualizado", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index e70f49c4ea..d2d75b2da2 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -7955,7 +7955,7 @@ }, "tokenTotalsAria": "Totaux de tokens de la tâche", "tokenUsage": "Utilisation des tokens", - "totalExecutionTime": "Temps d'exécution total", + "totalExecutionTime": "Temps actif total", "totalTokens": "Total", "updateFailed": "Échec de la mise à jour de {{id}} : {{error}}", "updateSuccess": "{{id}} mis à jour", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index dc45209c9b..63ccd80f87 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -7955,7 +7955,7 @@ }, "tokenTotalsAria": "작업 토큰 합계", "tokenUsage": "토큰 사용량", - "totalExecutionTime": "총 실행 시간", + "totalExecutionTime": "총 활성 시간", "totalTokens": "합계", "updateFailed": "{{id}} 업데이트 실패: {{error}}", "updateSuccess": "{{id}} 업데이트됨", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 71899644ec..a882fe0735 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -7955,7 +7955,7 @@ }, "tokenTotalsAria": "任务 Token 总计", "tokenUsage": "Token 使用情况", - "totalExecutionTime": "总执行时间", + "totalExecutionTime": "总活跃时间", "totalTokens": "总计", "updateFailed": "更新 {{id}} 失败:{{error}}", "updateSuccess": "已更新 {{id}}", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 26e5e3ba3e..438dd051aa 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -7955,7 +7955,7 @@ }, "tokenTotalsAria": "任務 Token 總計", "tokenUsage": "Token 使用量", - "totalExecutionTime": "總執行時間", + "totalExecutionTime": "總活躍時間", "totalTokens": "總計", "updateFailed": "更新 {{id}} 失敗:{{error}}", "updateSuccess": "已更新 {{id}}",