diff --git a/.changeset/fn-8296-feature.md b/.changeset/fn-8296-feature.md new file mode 100644 index 0000000000..630bad27f4 --- /dev/null +++ b/.changeset/fn-8296-feature.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Request and observe task E2E verification from chat. +category: feature +dev: Adds executor-owned verification request/status tools with allowlisted profiles. diff --git a/docs/agent-tool-surface-full-loop.md b/docs/agent-tool-surface-full-loop.md index 3a5c3f54ac..8c5bbbcba0 100644 --- a/docs/agent-tool-surface-full-loop.md +++ b/docs/agent-tool-surface-full-loop.md @@ -116,12 +116,12 @@ The following phases are intentionally independently shippable. Follow-up task I - **Dependency:** Phase A for lineage data, and the tracked FR-02/FR-37/FR-48 work if those tasks already cover portions of this phase. - **Tracking:** FN-8298 — Phase D implementation task; depends on FN-8294 and FN-8297. -### Phase E — Chat-owned verification request/status +### Phase E — Chat-owned verification request/status — delivered (FN-8296) -- **Scope anchors:** `packages/engine/src/run-verification-tool.ts`, executor task lifecycle/verification persistence, `packages/dashboard/src/chat.ts`, task/Command Center UI, verification tests and docs. -- **Acceptance:** chat can request and observe verification for a selected executable task, but the command still runs only through the task-owned executor/worktree; project verification concurrency and permission policy remain in force; no raw chat subprocess tool is added. -- **Dependency:** can ship independently of A–D, but must integrate with Phase D’s admission/locking diagnostics when dispatching a task. -- **Tracking:** FN-8296 — Phase E implementation task; deliberately independent from Phase A so it can land safely first. +- **Shipped tools:** `fn_task_request_verification` queues only the server-resolved `verify:fast` or configured `test-command` profile; `fn_task_verification_status` returns the latest bounded persisted result. Neither accepts raw command text. +- **Execution contract:** the request is project-scoped and CAS-claimed by the in-progress task executor, which reuses its live worktree and `runVerificationCommand`/`withVerificationSlot` bounds. `fn_task_request_verification` is classified as `command_execution`; status is read-only. +- **Parity:** chat can request/observe the same executor-owned verification outcome. Duplicate in-flight requests retain their original request ID rather than replacing work in progress. +- **Dependency:** remains independently shippable from A–D; Phase D diagnostics can be added to records later without changing the ownership contract. ## Follow-up reconciliation record diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 07d837101d..9712ea0e9a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2099,3 +2099,7 @@ In **Settings → General**, choose **Review draft before filing** (the default) Reports can include a short activity trace of recent built-in view names (up to 20 entries). The trace is ordinary text and receives the same mandatory server-side scrub as every other report field on every egress path, including edited drafts and duplicate endorsements. Choose **Attach a screenshot** to request the browser's screen-capture permission and capture one PNG frame. The modal shows the image for review and lets you remove it before continuing. Screenshot pixels are binary and cannot be text-scrubbed, so Fusion never captures or files one automatically: it is included only after this explicit per-report choice, including in automatic filing mode. Fusion first validates and files the scrubbed text report, then hosts and posts the reviewed image as a follow-up only when an approved GitHub image host is available. If that follow-up fails after hosting, Fusion compensates by deleting the uploaded image; it never inserts an unhosted data URL into report text. + +## 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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3554df1525..6f4db08c0c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -148,6 +148,8 @@ export { type ResolvedAgentMemoryInclusionMode, } from "./agent-memory-mode.js"; export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js"; +/* FNXC:TaskVerificationRequest 2026-07-30-00:00: FN-8296 makes the persisted verification read model available to dashboard task and Command Center surfaces without exporting a subprocess runner. */ +export type { TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskVerificationProfile } from "./types.js"; export type { TaskCommitAssociation, TaskCommitAssociationConfidence, diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index dbba5a32a1..6617624aa1 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -282,6 +282,26 @@ CREATE TABLE IF NOT EXISTS project.task_workflow_selection ( updated_at text NOT NULL ); +-- FNXC:TaskVerificationRequest 2026-07-30-00:00: chat queues profiles; only executor runs the resolved command. +CREATE TABLE IF NOT EXISTS project.task_verification_requests ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), + task_id text NOT NULL, + request_id text NOT NULL, + status text NOT NULL, + profile text NOT NULL, + command text NOT NULL, + scope text NOT NULL, + requested_by text NOT NULL, + requested_at text NOT NULL, + started_at text, + completed_at text, + result jsonb, + rejection_reason text, + PRIMARY KEY (project_id, task_id), + UNIQUE (project_id, request_id) +); +CREATE INDEX IF NOT EXISTS idx_task_verification_requests_status ON project.task_verification_requests(project_id, status, requested_at); + CREATE TABLE IF NOT EXISTS project.activity_log ( project_id text NOT NULL, id text PRIMARY KEY, diff --git a/packages/core/src/postgres/migrations/0024_task_verification_request.sql b/packages/core/src/postgres/migrations/0024_task_verification_request.sql new file mode 100644 index 0000000000..874e29bdba --- /dev/null +++ b/packages/core/src/postgres/migrations/0024_task_verification_request.sql @@ -0,0 +1,20 @@ +-- FNXC:TaskVerificationRequest 2026-07-30-00:00: durable, project-scoped chat-to-executor verification queue. +CREATE TABLE IF NOT EXISTS project.task_verification_requests ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), + task_id text NOT NULL, + request_id text NOT NULL, + status text NOT NULL, + profile text NOT NULL, + command text NOT NULL, + scope text NOT NULL, + requested_by text NOT NULL, + requested_at text NOT NULL, + started_at text, + completed_at text, + result jsonb, + rejection_reason text, + PRIMARY KEY (project_id, task_id), + UNIQUE (project_id, request_id) +); +CREATE INDEX IF NOT EXISTS idx_task_verification_requests_status + ON project.task_verification_requests(project_id, status, requested_at); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 4b6a5b5857..7fb5e4376e 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -33,7 +33,7 @@ import { acquireSchemaMutationLocks } from "./advisory-locks.js"; FNXC:GitHubImportTranslate 2026-07-17-23:48: Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0023"; +export const SCHEMA_BASELINE_VERSION = "0024"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -112,6 +112,8 @@ export const CONFIGURATION_REVISIONS_VERSION = "0021"; export const IDEATION_SCHEMA_VERSION = "0022"; /** FNXC:ResearchMissionBridge 2026-07-18-12:00: forward migration stores stable research finding provenance on canonical features. */ export const RESEARCH_FEATURE_PROVENANCE_VERSION = "0023"; +/** FNXC:TaskVerificationRequest 2026-07-30-00:00: upgrades need the project-scoped chat-to-executor verification queue. */ +export const TASK_VERIFICATION_REQUEST_VERSION = "0024"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -222,6 +224,7 @@ const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_propo const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql"); const IDEATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0022_ideation.sql"); const RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0023_research_feature_provenance.sql"); +const TASK_VERIFICATION_REQUEST_MIGRATION_PATH = join(MIGRATIONS_DIR, "0024_task_verification_request.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -314,6 +317,7 @@ export async function applySchemaBaseline( const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION); const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION); const researchFeatureProvenanceAlreadyApplied = applied.includes(RESEARCH_FEATURE_PROVENANCE_VERSION); + const taskVerificationRequestAlreadyApplied = applied.includes(TASK_VERIFICATION_REQUEST_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -684,6 +688,13 @@ export async function applySchemaBaseline( schemaChanged = true; } + if (!taskVerificationRequestAlreadyApplied) { + const migrationSql = await readFile(TASK_VERIFICATION_REQUEST_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_VERIFICATION_REQUEST_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + if (!importTranslationCacheLegacyPartitionBackfillAlreadyApplied) { const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_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 62ffda9013..b8d5c5eaa7 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -562,6 +562,31 @@ export const taskWorkflowSelection = projectSchema.table("task_workflow_selectio updatedAt: text("updated_at").notNull(), }, (t) => [primaryKey({ columns: [t.projectId, t.taskId] })]); +/* +FNXC:TaskVerificationRequest 2026-07-30-00:00: +One latest request per project/task gives chat an observable queue while CAS writes +prevent stale executor completions from overwriting a later request. +*/ +export const taskVerificationRequests = projectSchema.table("task_verification_requests", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + taskId: text("task_id").notNull(), + requestId: text("request_id").notNull(), + status: text("status").notNull(), + profile: text("profile").notNull(), + command: text("command").notNull(), + scope: text("scope").notNull(), + requestedBy: text("requested_by").notNull(), + requestedAt: text("requested_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + result: jsonb("result"), + rejectionReason: text("rejection_reason"), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.taskId] }), + unique("task_verification_requests_project_request_id_unique").on(t.projectId, t.requestId), + index("idx_task_verification_requests_status").on(t.projectId, t.status, t.requestedAt), +]); + // ── Activity log ───────────────────────────────────────────────────── export const activityLog = projectSchema.table("activity_log", { // FNXC:AnalyticsIsolation 2026-07-13-23:41: Shared PostgreSQL telemetry must carry an explicit project partition; dashboard ranges must never aggregate another project's activity. diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 21fe857674..fef29e1c02 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -99,7 +99,7 @@ import { pgRowToTaskRow as pgRowToTaskRowExternal, rowToTask as rowToTaskExterna import { moveTaskImpl, handoffToReviewImpl, moveTaskInternalImpl } from "./task-store/moves.js"; import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAvailableImpl, atomicWriteTaskJsonImpl2, createTaskWithDistributedReservationImpl, toStoredWorkflowStepImpl, ensureWorkflowStepForTemplateImpl, resolveEnabledWorkflowStepsImpl, setTaskBranchGroupImpl, getTaskColumnsImpl, prepareWorkflowMovePolicyPreflightImpl, updateTaskCustomFieldsImpl, listWorkflowPromptOverridesForProjectImpl, listWorkflowWorkItemsForTaskImpl, listDueWorkflowWorkItemsImpl, rewriteBlockedByResidueDependentsForRemovalImpl, getAllDocumentsImpl, deleteWorkflowStepImpl, toWorkflowDefinitionImpl, materializeDefaultWorkflowStepsImpl, reconcileTaskCustomFieldsForSchemaImpl, getTaskMovedCountsByDayImpl, getGoalStoreImpl, upsertTaskCommitAssociationImpl } from "./task-store/remaining-ops-4.js"; import { applyLegacyWorkflowStepOverridesImpl, archiveDbImpl, assertNoDependencyCycleImpl, atomicCreateTaskJsonImpl, buildActiveTaskDependencyLookupImpl, buildArchivedAgentLogFieldsImpl, buildTaskIdIntegrityFallbackReportImpl, createBranchGroupImpl, dbImpl, detectAndCacheTaskIdIntegrityReportImpl, findLiveDependentsImpl, findLiveLineageChildrenImpl, getLegacyWorkflowStepSnapshotImpl, getMalformedTaskMetadataReasonImpl, getMergeQueuedTaskIdsAsyncImpl, insertRunAuditEventRowImpl, insertTaskImpl, invokeTaskCreatedHookImpl, isTaskArchivedImpl, isTaskIdPresentInArchivedTasksTableImpl, logTaskCreateConflictImpl, maybeResolveTombstonedTaskIdImpl, mergeTaskIdIntegrityReportsImpl, optionalGroupIdSetImpl, patchTaskRowInTransactionImpl, readConfigFastImpl, readConfigImpl, readPromptForArchiveImpl, readTaskFromDbImpl, reconcileDistributedTaskIdStateOnOpenImpl, recordActivityFromListenerImpl, recordDependencyCycleRejectedAuditImpl, refreshTaskIdIntegrityReportImpl, resolveLocalNodeIdForTaskAllocationImpl, runTaskFtsWriteWithRecoveryImpl, scanAndRecordCitationsImpl, taskIdExistsAnywhereImpl, throwSoftDeletedWriteBlockedImpl, toBuiltInWorkflowStepImpl, trackDeferredTaskCreatedWorkImpl, upsertTaskImpl, withConfigLockImpl, withTaskLockImpl, withWorktreeAllocationLockImpl } from "./task-store/remaining-ops-5.js"; -import { claimNextToolFailureRetryImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/remaining-ops-6.js"; +import { claimNextToolFailureRetryImpl, createTaskVerificationRequestImpl, claimTaskVerificationRequestImpl, finishTaskVerificationRequestImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/remaining-ops-6.js"; import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStaleMergeQueueRowsImpl, clearCompletionHandoffAcceptedMarkerImpl, clearDoneTransientFieldsImpl, clearStaleExecutionStartBranchReferencesImpl, computeWorkflowColumnsGraduationReportImpl, deleteTaskCommentImpl, deleteTaskDocumentImpl, emitUsageEventImpl, enqueueMergeQueueImpl, getAgentLogCountImpl, getAgentLogsImpl, getArtifactImpl, getArtifactsImpl, getAttachmentImpl, getCompletionHandoffAcceptedMarkerImpl, getTaskDocumentImpl, getTaskDocumentRevisionsImpl, getTaskDocumentsImpl, insertArtifactRowImpl, linkGithubIssueImpl, listWorkflowWorkItemsForTaskSyncImpl, moveToDoneImpl, parseDependenciesFromPromptImpl, parseFileScopeFromPromptImpl, parseStepsFromPromptImpl, peekMergeQueueHeadImpl, peekMergeQueueImpl, readPreArchiveColumnFromTaskFileImpl, recordPluginActivationImpl, recordRunAuditEventBackendImpl, removePrInfoByNumberImpl, resolvePrimaryPrInfoImpl, resolveUnarchiveTargetColumnImpl, rewriteLineageChildrenForRemovalImpl, runGitCommandImpl, stopWatchingImpl, syncAgentTaskLinkOnReassignmentImpl, updateArtifactImpl, updateGithubTrackingImpl, updatePrInfoByNumberImpl, updateTaskCommentImpl, upsertPrInfoByNumberImpl, writeArtifactDataImpl } from "./task-store/remaining-ops-7.js"; import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getIdeationStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js"; import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js"; @@ -128,7 +128,7 @@ import { addCommentImpl, upsertTaskDocumentImpl } from "./task-store/comments-op import { deleteTaskImpl, archiveTaskImpl } from "./task-store/archive-lifecycle.js"; import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js"; import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.js"; -import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl } from "./task-store/reads.js"; +import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl, getTaskVerificationRequestAsyncImpl } from "./task-store/reads.js"; import { updateTaskUnlockedImpl } from "./task-store/task-update.js"; import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js"; // FNXC:RuntimeBackendAsync 2026-06-24-10:15: @@ -1417,6 +1417,18 @@ export class TaskStore extends EventEmitter { public isValidMergeRequestTransition(from: MergeRequestState, to: MergeRequestState): boolean { return isValidMergeRequestTransitionImpl(this, from, to); } + async createTaskVerificationRequest(input: Omit & { requestedAt?: string }) { + return createTaskVerificationRequestImpl(this, input); + } + async getTaskVerificationRequestAsync(taskId: string) { + return getTaskVerificationRequestAsyncImpl(this, taskId); + } + async claimTaskVerificationRequest(taskId: string, requestId: string) { + return claimTaskVerificationRequestImpl(this, taskId, requestId); + } + async finishTaskVerificationRequest(taskId: string, requestId: string, status: "passed" | "failed" | "rejected", result?: import("./types.js").TaskVerificationResultSummary, rejectionReason?: string) { + return finishTaskVerificationRequestImpl(this, taskId, requestId, status, result, rejectionReason); + } async upsertMergeRequestRecord( taskId: string, input: { state: MergeRequestState; now?: string; attemptCount?: number; lastError?: string | null }, ): Promise { return upsertMergeRequestRecordImpl(this, taskId, input); } diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index 93b09d2c56..5b9775b7ec 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -10,7 +10,9 @@ import {TaskStore, storeLog} from "../store.js"; import {readFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync, statSync} from "node:fs"; -import type {Task, TaskDetail, ColumnId, ArchivedTaskEntry} from "../types.js"; +import type {Task, TaskDetail, ColumnId, ArchivedTaskEntry, TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus} from "../types.js"; +import * as schema from "../postgres/schema/index.js"; +import { and, eq } from "drizzle-orm"; import "../builtin-traits.js"; import {allowsAutoMergeProcessing} from "../task-merge.js"; import {getInReviewStallReason, DEFAULT_STALE_MERGING_MIN_AGE_MS} from "../in-review-stall.js"; @@ -1079,3 +1081,13 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: const matches = [...activeMatches, ...archiveMatches]; return limit >= 0 ? matches.slice(0, limit) : matches; } + +/* FNXC:TaskVerificationRequest 2026-07-30-00:00: status reads are project-scoped so chat never observes another project's request. */ +export async function getTaskVerificationRequestAsyncImpl(store: TaskStore, taskId: string): Promise { + if (!store.backendMode) return null; + const layer = store.asyncLayer!; + const projectFilter = layer.projectId ? eq(schema.project.taskVerificationRequests.projectId, layer.projectId) : undefined; + const rows = await layer.db.select().from(schema.project.taskVerificationRequests).where(and(eq(schema.project.taskVerificationRequests.taskId, taskId), ...(projectFilter ? [projectFilter] : []))).limit(1); + const row = rows[0]; + return row ? { taskId: row.taskId, requestId: row.requestId, status: row.status as TaskVerificationStatus, profile: row.profile as TaskVerificationRequest["profile"], command: row.command, scope: row.scope as TaskVerificationRequest["scope"], requestedBy: row.requestedBy, requestedAt: row.requestedAt, ...(row.startedAt ? { startedAt: row.startedAt } : {}), ...(row.completedAt ? { completedAt: row.completedAt } : {}), ...(row.result ? { result: row.result as TaskVerificationResultSummary } : {}), ...(row.rejectionReason ? { rejectionReason: row.rejectionReason } : {}) } : null; +} diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index 9158f2e2a1..604566b711 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -18,7 +18,7 @@ import { ensureBranchGroupForSource as ensureBranchGroupForSourceAsync, ensurePr import { getWorkflowWorkItem as getWorkflowWorkItemAsync } from "./async-workflow-workitems.js"; import { type TaskRow } from "./persistence.js"; import { BranchGroupRow, MergeRequestRow, PrEntityRow, PrThreadStateRow, WorkflowWorkItemRow } from "./row-types.js"; -import { BranchGroup, BranchGroupCreateInput, ColumnId, MergeRequestRecord, MergeRequestState, PrEntity, PrEntityCreateInput, PrThreadOutcome, PrThreadState, RunMutationContext, Task, TaskLogEntry, TaskPriority, WorkflowWorkItem, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch } from "../types.js"; +import { BranchGroup, BranchGroupCreateInput, ColumnId, MergeRequestRecord, MergeRequestState, PrEntity, PrEntityCreateInput, PrThreadOutcome, PrThreadState, RunMutationContext, Task, TaskLogEntry, TaskPriority, TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, WorkflowWorkItem, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch } from "../types.js"; import { validateNodeOverrideChange } from "../node-override-guard.js"; import { WorkflowMovePolicyInput } from "../workflow-extension-types.js"; import { resolveWorkflowIrById } from "../workflow-ir-resolver.js"; @@ -1267,6 +1267,53 @@ export async function getMutationsForRunImpl(store: TaskStore, runId: string): P return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } +/* +FNXC:TaskVerificationRequest 2026-07-30-00:00: +The queue is intentionally one record per project/task. Creation refuses an in-flight +record and both claim/terminal writes compare request_id so executor races stay safe. +*/ +function verificationScope(store: TaskStore) { + const projectId = store.asyncLayer?.projectId; + return projectId ? eq(schema.project.taskVerificationRequests.projectId, projectId) : undefined; +} +function verificationRowToRecord(row: typeof schema.project.taskVerificationRequests.$inferSelect): TaskVerificationRequest { + return { + taskId: row.taskId, requestId: row.requestId, status: row.status as TaskVerificationStatus, + profile: row.profile as TaskVerificationRequest["profile"], command: row.command, + scope: row.scope as TaskVerificationRequest["scope"], requestedBy: row.requestedBy, + requestedAt: row.requestedAt, ...(row.startedAt ? { startedAt: row.startedAt } : {}), + ...(row.completedAt ? { completedAt: row.completedAt } : {}), + ...(row.result ? { result: row.result as TaskVerificationResultSummary } : {}), + ...(row.rejectionReason ? { rejectionReason: row.rejectionReason } : {}), + }; +} +export async function createTaskVerificationRequestImpl(store: TaskStore, input: Omit & { requestedAt?: string }): Promise<{ request?: TaskVerificationRequest; inFlightRequestId?: string }> { + if (!store.backendMode) throw new Error("Task verification requests require PostgreSQL persistence"); + const layer = store.asyncLayer!; + return layer.db.transaction(async (tx) => { + const where = verificationScope(store); + const rows = await tx.select().from(schema.project.taskVerificationRequests).where(and(eq(schema.project.taskVerificationRequests.taskId, input.taskId), ...(where ? [where] : []))).limit(1); + const existing = rows[0]; + if (existing && (existing.status === "requested" || existing.status === "running")) return { inFlightRequestId: existing.requestId }; + const requestedAt = input.requestedAt ?? new Date().toISOString(); + const values = { ...input, status: "requested" as const, requestedAt, startedAt: null, completedAt: null, result: null, rejectionReason: null, projectId: layer.projectId ?? "__legacy_unscoped__" }; + await tx.insert(schema.project.taskVerificationRequests).values(values).onConflictDoUpdate({ target: [schema.project.taskVerificationRequests.projectId, schema.project.taskVerificationRequests.taskId], set: values }); + return { request: verificationRowToRecord(values) }; + }); +} +export async function claimTaskVerificationRequestImpl(store: TaskStore, taskId: string, requestId: string): Promise { + if (!store.backendMode) return null; + const startedAt = new Date().toISOString(); const where = verificationScope(store); + const rows = await store.asyncLayer!.db.update(schema.project.taskVerificationRequests).set({ status: "running", startedAt }).where(and(eq(schema.project.taskVerificationRequests.taskId, taskId), eq(schema.project.taskVerificationRequests.requestId, requestId), eq(schema.project.taskVerificationRequests.status, "requested"), ...(where ? [where] : []))).returning(); + return rows[0] ? verificationRowToRecord(rows[0]) : null; +} +export async function finishTaskVerificationRequestImpl(store: TaskStore, taskId: string, requestId: string, status: Extract, result?: TaskVerificationResultSummary, rejectionReason?: string): Promise { + if (!store.backendMode) return null; + const where = verificationScope(store); + const rows = await store.asyncLayer!.db.update(schema.project.taskVerificationRequests).set({ status, completedAt: new Date().toISOString(), result: result ?? null, rejectionReason: rejectionReason ?? null }).where(and(eq(schema.project.taskVerificationRequests.taskId, taskId), eq(schema.project.taskVerificationRequests.requestId, requestId), eq(schema.project.taskVerificationRequests.status, "running"), ...(where ? [where] : []))).returning(); + return rows[0] ? verificationRowToRecord(rows[0]) : null; +} + export function normalizeMergeRequestStateImpl(store: TaskStore, value: string): MergeRequestState { switch (value) { case "queued": diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3247851cde..68ed610d7f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2132,6 +2132,36 @@ export type RetrySummary = { total: number; }; +/* +FNXC:TaskVerificationRequest 2026-07-30-00:00: +Chat may request only a server-resolved verification profile. The persisted record +keeps executor-owned subprocess results observable without exposing raw commands. +*/ +export type TaskVerificationStatus = "requested" | "running" | "passed" | "failed" | "rejected"; +export type TaskVerificationProfile = "verify:fast" | "test-command"; +export interface TaskVerificationResultSummary { + success: boolean; + exitCode: number | null; + durationMs: number; + timedOut: boolean; + stdoutTail: string; + stderrTail: string; +} +export interface TaskVerificationRequest { + taskId: string; + requestId: string; + status: TaskVerificationStatus; + profile: TaskVerificationProfile; + command: string; + scope: "package" | "workspace"; + requestedBy: string; + requestedAt: string; + startedAt?: string; + completedAt?: string; + result?: TaskVerificationResultSummary; + rejectionReason?: string; +} + export interface TaskDetail extends Task { prompt: string; /** Derived aggregate of retry counters (computed on read; never persisted). */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index d73722b69a..1fb7817bf2 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -243,6 +243,7 @@ export { fetchAgentLogs, fetchAgentLogsWithMeta, fetchSessionFiles, + fetchTaskVerificationRequest, fetchTaskComments, addTaskComment, updateTaskComment, diff --git a/packages/dashboard/app/api/task-content.ts b/packages/dashboard/app/api/task-content.ts index becf99e137..2179417402 100644 --- a/packages/dashboard/app/api/task-content.ts +++ b/packages/dashboard/app/api/task-content.ts @@ -15,11 +15,21 @@ import type { NativeStructureRef, NativeStructurePreviewResult, AgentLogEntry, + TaskVerificationRequest, } from "@fusion/core"; import { appendTokenQuery, withTokenHeader } from "../auth"; import { api, buildApiUrl } from "./client.js"; import { withProjectId } from "./health.js"; +/** + * FNXC:TaskVerificationStatus 2026-07-30-00:00: + * Task detail polls this persisted read model because verification state changes do + * not mutate the task row and therefore do not emit a task-board SSE update. + */ +export function fetchTaskVerificationRequest(taskId: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/verification-request`, projectId)); +} + export async function uploadAttachment(id: string, file: File, projectId?: string): Promise { const formData = new FormData(); formData.append("file", file); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index fd911c2349..3fbcb1fe40 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -11,7 +11,7 @@ import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline"; -import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction, TaskGitLabTrackedItem, PlannerOversightLevel, PlannerOverseerRuntimeSnapshot } from "@fusion/core"; +import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction, TaskGitLabTrackedItem, PlannerOversightLevel, PlannerOverseerRuntimeSnapshot, TaskVerificationRequest } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, @@ -24,12 +24,13 @@ import { resolveTaskSessionAdvisorEnabled } from "../../../core/src/session-advi import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api"; import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api"; import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { WorkflowIcon } from "./WorkflowIcon"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; +import { TaskVerificationStatus } from "./TaskVerificationStatus"; import type { ToastType } from "../hooks/useToast"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { useConfirm } from "../hooks/useConfirm"; @@ -690,6 +691,17 @@ export function TaskDetailContent({ const [detailLoading, setDetailLoading] = useState(() => !("prompt" in task), ); + const [verificationRequest, setVerificationRequest] = useState(null); + + useEffect(() => { + let cancelled = false; + const refresh = () => void fetchTaskVerificationRequest(task.id, projectId) + .then((request) => { if (!cancelled) setVerificationRequest(request); }) + .catch(() => { if (!cancelled) setVerificationRequest(null); }); + refresh(); + const timer = window.setInterval(refresh, 5_000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [task.id, projectId]); useEffect(() => { // If the prop already has a prompt field, it's a full TaskDetail @@ -4268,6 +4280,7 @@ export function TaskDetailContent({

)} +
{/* FNXC:QuickAddActionRow 2026-07-16-16:00: diff --git a/packages/dashboard/app/components/TaskVerificationStatus.css b/packages/dashboard/app/components/TaskVerificationStatus.css new file mode 100644 index 0000000000..143668ca99 --- /dev/null +++ b/packages/dashboard/app/components/TaskVerificationStatus.css @@ -0,0 +1,94 @@ +/* +FNXC:TaskVerificationStatus 2026-07-30-00:00: +FN-8296 requires task and Command Center verification state to use the same +semantic pending/error colors and remain readable at the shared mobile breakpoint. +*/ +.task-verification-status { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); + padding: var(--space-md); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-1); + color: var(--text-muted); + font-size: 0.8125rem; +} + +.task-verification-status__heading { + display: flex; + align-items: center; + gap: var(--space-sm); + color: var(--text); +} + +.task-verification-status__heading svg { + inline-size: var(--icon-size-sm); + block-size: var(--icon-size-sm); +} + +.task-verification-status__state { + margin-inline-start: auto; + color: var(--text-muted); + text-transform: capitalize; +} + +.task-verification-status p { + margin: 0; +} + +.task-verification-status--requested, +.task-verification-status--running { + border-color: color-mix(in srgb, var(--color-warning) 35%, var(--border-subtle)); +} + +.task-verification-status--requested .task-verification-status__heading, +.task-verification-status--running .task-verification-status__heading { + color: var(--color-warning); +} + +.task-verification-status--failed, +.task-verification-status--rejected .task-verification-status__heading { + color: var(--color-error); +} + +.task-verification-status--passed .task-verification-status__heading { + color: var(--color-success); +} + +.task-verification-status__spinner { + animation: task-verification-spin var(--duration-slow) linear infinite; +} + +.task-verification-status__output { + max-block-size: calc(var(--space-2xl) * 5); + margin: 0; + overflow: auto; + color: var(--color-error); + font: inherit; + white-space: pre-wrap; +} + +.task-verification-status--compact { + margin-bottom: 0; +} + +.task-verification-status--empty { + color: var(--text-muted); +} + +@keyframes task-verification-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 768px) { + .task-verification-status__heading { + align-items: flex-start; + flex-wrap: wrap; + } + + .task-verification-status__state { + margin-inline-start: 0; + } +} diff --git a/packages/dashboard/app/components/TaskVerificationStatus.tsx b/packages/dashboard/app/components/TaskVerificationStatus.tsx new file mode 100644 index 0000000000..f13a0fcd02 --- /dev/null +++ b/packages/dashboard/app/components/TaskVerificationStatus.tsx @@ -0,0 +1,39 @@ +import type { TaskVerificationRequest } from "@fusion/core"; +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; +import "./TaskVerificationStatus.css"; + +function formatDuration(durationMs: number | undefined): string | null { + if (typeof durationMs !== "number") return null; + return `${(durationMs / 1000).toFixed(1)}s`; +} + +/** + * FNXC:TaskVerificationStatus 2026-07-30-00:00: + * FN-8296 exposes executor-owned verification as persisted state in every human + * surface. This component deliberately renders a record only; it never offers a + * command control or reimplements the chat/executor permission boundary. + */ +export function TaskVerificationStatus({ request, compact = false }: { request: TaskVerificationRequest | null; compact?: boolean }) { + if (!request) return compact ? null :

No chat verification requested.

; + + const running = request.status === "requested" || request.status === "running"; + const failed = request.status === "failed" || request.status === "rejected"; + const Icon = running ? Loader2 : failed ? AlertCircle : CheckCircle2; + const summary = request.status === "rejected" + ? request.rejectionReason ?? "Request rejected" + : request.result + ? `${request.result.success ? "Passed" : "Failed"}${formatDuration(request.result.durationMs) ? ` · ${formatDuration(request.result.durationMs)}` : ""}` + : request.status === "requested" ? "Queued for the task executor" : "Running in the task worktree"; + + return ( +
+
+
+

{summary}

+ {!compact && request.result?.stderrTail ?
{request.result.stderrTail}
: null} +
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/TaskVerificationStatus.test.tsx b/packages/dashboard/app/components/__tests__/TaskVerificationStatus.test.tsx new file mode 100644 index 0000000000..9fb31ea73b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskVerificationStatus.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { TaskVerificationRequest } from "@fusion/core"; +import { TaskVerificationStatus } from "../TaskVerificationStatus"; + +const base: TaskVerificationRequest = { + taskId: "FN-8296", + requestId: "request-1", + profile: "verify:fast", + command: "pnpm verify:fast", + scope: "workspace", + requestedBy: "chat", + requestedAt: "2026-07-30T00:00:00.000Z", + status: "requested", +}; + +describe("TaskVerificationStatus", () => { + it("renders an empty state without a request", () => { + render(); + expect(screen.getByText("No chat verification requested.")).toBeInTheDocument(); + }); + + it.each(["requested", "running", "passed", "failed", "rejected"] as const)("renders the %s lifecycle state", (status) => { + render(); + expect(screen.getByTestId("task-verification-status")).toHaveClass(`task-verification-status--${status}`); + expect(screen.getByText(status, { exact: true })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index 2cc382fda5..10e00d599e 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -152,6 +152,39 @@ The System tab combines a controls fragment with a stats .cc-area. Keep the tabp gap: var(--space-lg); } +/* +FNXC:TaskVerificationStatus 2026-07-30-00:00: +FN-8296 keeps Command Center's compact verification feed in the existing card +rhythm. Task IDs remain visible beside the shared status component on desktop and +stack with it at the standard mobile breakpoint. +*/ +.cc-verification-requests { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); +} + +.cc-verification-requests__item { + display: grid; + grid-template-columns: minmax(calc(var(--space-2xl) * 2), auto) minmax(0, 1fr); + gap: var(--space-sm); + align-items: start; +} + +.cc-verification-requests__task { + padding: var(--space-sm) 0; + color: var(--text-muted); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 768px) { + .cc-verification-requests__item { + grid-template-columns: 1fr; + } +} + .cc-stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, calc(var(--space-2xl) * 5)), 1fr)); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index bc63eb126f..27f728b2d9 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Gauge } from "lucide-react"; -import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core"; +import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics, TaskVerificationRequest } from "@fusion/core"; import { api, fetchCodebaseMetrics, withProjectId, type CodebaseMetrics } from "../../api/legacy"; import { formatBytes } from "../../utils/formatBytes"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; import { LoadingSpinner } from "../LoadingSpinner"; +import { TaskVerificationStatus } from "../TaskVerificationStatus"; import { TokensArea } from "./areas/TokensArea"; import { ToolsArea } from "./areas/ToolsArea"; import { ActivityArea } from "./areas/ActivityArea"; @@ -169,6 +170,17 @@ function OverviewTab({ const [liveSnapshot, setLiveSnapshot] = useState(null); const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true); const [codebaseMetrics, setCodebaseMetrics] = useState(null); + const [verificationRequests, setVerificationRequests] = useState([]); + + useEffect(() => { + let cancelled = false; + const refresh = () => void api<{ requests: TaskVerificationRequest[] }>(withProjectId("/command-center/verification-requests", projectId)) + .then((response) => { if (!cancelled) setVerificationRequests(response.requests); }) + .catch(() => { if (!cancelled) setVerificationRequests([]); }); + refresh(); + const timer = window.setInterval(refresh, OVERVIEW_TOKEN_REFRESH_MS); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [projectId]); useEffect(() => { let cancelled = false; @@ -374,6 +386,25 @@ function OverviewTab({
); + /* + FNXC:TaskVerificationRequest 2026-07-30-17:40: + Verification is operational state, not analytics. Render it in every settled + Overview branch so an otherwise new project still exposes executor outcomes. + */ + const verificationSection = verificationRequests.length > 0 ? ( +
+
+

Task verification

+

Latest executor-owned verification requests

+
+ {verificationRequests.map((request) => ( +
+ {request.taskId} + +
+ ))} +
+ ) : null; if (isInitialLoading) { return ( @@ -384,6 +415,7 @@ function OverviewTab({

+ {verificationSection} {throughputSection} ); @@ -398,6 +430,7 @@ function OverviewTab({

{coreError}

+ {verificationSection} {throughputSection} ); @@ -412,6 +445,7 @@ function OverviewTab({

{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Dashboard.")}

+ {verificationSection} {throughputSection} ); @@ -459,6 +493,7 @@ function OverviewTab({ /> + {verificationSection} {hasOverviewChartData ? ( /* FNXC:CommandCenter 2026-06-18-00:00: diff --git a/packages/dashboard/src/__tests__/chat.test.ts b/packages/dashboard/src/__tests__/chat.test.ts index 5745d0ee7a..6c608d9f69 100644 --- a/packages/dashboard/src/__tests__/chat.test.ts +++ b/packages/dashboard/src/__tests__/chat.test.ts @@ -76,6 +76,7 @@ vi.mock("@fusion/engine", () => ({ createResearchTools: vi.fn(() => []), resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })), resolveExecutorThinkingLevel: vi.fn(() => undefined), + wrapToolsWithActionGate: vi.fn((tools) => tools), /* FNXC:ChatToolset 2026-07-15-16:35: FN-7987 exposed the shared fusion toolset to chat agents, adding these `@fusion/engine` imports to diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 911862e7ea..e00c31ef64 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -38,6 +38,7 @@ import { FUSION_RUNTIME_SELF_AWARENESS, } from "@fusion/core"; import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { join, resolve, relative } from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; @@ -81,6 +82,7 @@ import { createResearchTools, resolveMcpServersForStore, resolveExecutorThinkingLevel, + wrapToolsWithActionGate, } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; @@ -358,6 +360,8 @@ export interface ChatFusionToolsetOptions { agentId?: string; /** True only when the session has both policy gate contexts for its bound agent. */ missionMutationGated?: boolean; + /** Required for command-execution requests; status remains safely readable without it. */ + actionGateContext?: AgentActionGateContext; } const CHAT_MISSION_READ_TOOL_NAMES = new Set(["fn_mission_list", "fn_mission_show"]); @@ -452,6 +456,54 @@ room-responder lanes. Workflow, document, artifact, messaging, and task-planner tools stay additive at their call sites; unbound chat retains only read-only Mission tools because it has no durable action-gate principal. */ +/* +FNXC:TaskVerificationRequest 2026-07-30-00:00: +Chat records an allowlisted profile only. It deliberately has no shell runner: the +executor claims this record on its existing task worktree under the shared slot. +*/ +function createTaskVerificationTools(taskStore: TaskStore, actionGateContext?: AgentActionGateContext): ChatCustomTool[] { + const profiles = new Set(["verify:fast", "test-command"]); + const request = { + name: "fn_task_request_verification", label: "Request Task Verification", + description: "Queue an allowlisted verification profile for an in-progress task with a live executor worktree. This only records a request; chat never runs a command.", + parameters: { type: "object", properties: { task_id: { type: "string" }, profile: { type: "string", enum: ["verify:fast", "test-command"] } }, required: ["task_id"], additionalProperties: false }, + execute: async (_id: string, raw: { task_id?: unknown; profile?: unknown }) => { + const taskId = typeof raw.task_id === "string" ? raw.task_id.trim() : ""; + const profile = typeof raw.profile === "string" ? raw.profile : "verify:fast"; + if (!taskId || !profiles.has(profile)) return { content: [{ type: "text" as const, text: "ERROR: task_id and an allowlisted profile are required; raw commands are not accepted." }], isError: true, details: {} }; + const task = await taskStore.getTask(taskId); + if (!task || task.column !== "in-progress" || !task.worktree || !existsSync(task.worktree)) return { content: [{ type: "text" as const, text: "ERROR: verification requires an in-progress task with a live executor worktree." }], isError: true, details: {} }; + const settings = await taskStore.getSettings(); + const command = profile === "verify:fast" ? "pnpm verify:fast" : typeof settings.testCommand === "string" ? settings.testCommand : ""; + if (!command) return { content: [{ type: "text" as const, text: "ERROR: the selected verification profile is not configured." }], isError: true, details: {} }; + const created = await taskStore.createTaskVerificationRequest({ taskId, requestId: randomUUID(), profile: profile as "verify:fast" | "test-command", command, scope: "workspace", requestedBy: "chat" }); + if (created.inFlightRequestId) return { content: [{ type: "text" as const, text: `ERROR: verification request ${created.inFlightRequestId} is already in flight.` }], isError: true, details: created }; + return { content: [{ type: "text" as const, text: `Queued verification request ${created.request!.requestId} for ${taskId}.` }], details: created.request }; + }, + } as ChatCustomTool; + const status = { + name: "fn_task_verification_status", label: "Get Task Verification Status", description: "Read the latest persisted task verification request and bounded result.", + parameters: { type: "object", properties: { task_id: { type: "string" } }, required: ["task_id"], additionalProperties: false }, + execute: async (_id: string, raw: { task_id?: unknown }) => { + const taskId = typeof raw.task_id === "string" ? raw.task_id.trim() : ""; + if (!taskId) return { content: [{ type: "text" as const, text: "ERROR: task_id is required." }], isError: true, details: {} }; + const record = await taskStore.getTaskVerificationRequestAsync(taskId); + return { content: [{ type: "text" as const, text: record ? JSON.stringify(record) : `No verification request exists for ${taskId}.` }], details: record ?? {} }; + }, + } as ChatCustomTool; + /* + FNXC:TaskVerificationRequest 2026-07-30-17:40: + A chat verification request is command_execution even though this closure only + persists a queue row. Apply the engine's action-gate at this server boundary + before persistence; sessions without a durable principal may read status but + must not enqueue executor-owned subprocess work. + */ + return [ + ...(actionGateContext ? wrapToolsWithActionGate([request], actionGateContext) as ChatCustomTool[] : []), + status, + ]; +} + export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise { const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false } = options; const tools: ChatCustomTool[] = []; @@ -462,6 +514,7 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions) createTaskListTool(taskStore), createTaskShowTool(taskStore), createTaskSearchTool(taskStore), + ...createTaskVerificationTools(taskStore, options.actionGateContext), createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }), /* FNXC:ResearchMissionBridge 2026-07-18-12:00: Promotion is a mission mutation because it creates canonical roadmap work; dashboard chat exposes it only through the same permanent-agent action gate as all hierarchy writes. */ ...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)), @@ -1966,6 +2019,7 @@ export class ChatManager { rootDir: this.rootDir, agentId: input.responder.id, missionMutationGated: missionGateContexts.missionMutationGated, + actionGateContext: missionGateContexts.actionGateContext, }); const resolvedSession = await createResolvedAgentSession({ @@ -2525,6 +2579,7 @@ export class ChatManager { rootDir: this.rootDir, agentId: agent?.id, missionMutationGated: missionGateContexts.missionMutationGated, + actionGateContext: missionGateContexts.actionGateContext, }); const customTools = dedupeChatTools([ createAskQuestionTool(), diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index d9e78ffbe8..47b9d3dd9a 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -517,6 +517,26 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { } }); + /* + FNXC:TaskVerificationStatus 2026-07-30-00:00: + Command Center shares the persisted executor verification outcomes with task + detail. Resolve every task through this request-scoped store so results never + cross project boundaries. + */ + router.get("/command-center/verification-requests", async (req, res) => { + try { + const store = await getScopedStore(req); + const tasks = await store.listTasks({ limit: 50, includeArchived: false, slim: true }); + const records = (await Promise.all(tasks.map((task) => store.getTaskVerificationRequestAsync(task.id)))) + .filter((record): record is NonNullable => record !== null) + .sort((a, b) => b.requestedAt.localeCompare(a.requestedAt)); + res.json({ requests: records.slice(0, 10) }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to read verification requests"); + } + }); + /** * GET /api/command-center/live * Live Mission-Control snapshot (U6a): active sessions/runs/nodes + current diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 81f5a9ff80..0bbb774cd4 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -3237,6 +3237,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /* + FNXC:TaskVerificationStatus 2026-07-30-00:00: + FN-8296 exposes the executor-owned verification read model through a scoped + route. The client polls this record independently because it is not a task-row + mutation and should not fabricate a board update just to refresh status. + */ + router.get("/tasks/:id/verification-request", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + await scopedStore.getTask(req.params.id); + res.json(await scopedStore.getTaskVerificationRequestAsync(req.params.id)); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to read task verification status"); + } + }); + // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c0aea2e51b..fce38efb16 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -268,7 +268,7 @@ import { isResearchToolSurfaceEnabled, } from "./tool-availability.js"; import { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js"; -import { createRunVerificationTool } from "./run-verification-tool.js"; +import { createRunVerificationTool, runVerificationCommand as runTaskVerificationCommand } from "./run-verification-tool.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { recordRetry } from "./retry-burned-logger.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; @@ -11654,6 +11654,43 @@ export class TaskExecutor { executorLog.log(`${task.id}: fast mode — fn_review_step tool not injected`); } + /* + FNXC:TaskVerificationRequest 2026-07-30-00:00: + Chat can only enqueue a server-resolved profile. The executor owns the live + worktree, so it claims and runs that request here through the existing bounded + runner (which acquires withVerificationSlot); no chat-side subprocess exists. + */ + let verificationRequestInFlight = false; + const runPendingTaskVerification = async (): Promise => { + if (verificationRequestInFlight) return; + const pendingVerification = await this.store.getTaskVerificationRequestAsync(task.id); + if (pendingVerification?.status !== "requested") return; + verificationRequestInFlight = true; + try { + const claimedVerification = await this.store.claimTaskVerificationRequest(task.id, pendingVerification.requestId); + if (!claimedVerification) return; + const startedAt = Date.now(); + try { + const verificationResult = await runTaskVerificationCommand({ + command: claimedVerification.command, + cwd: worktreePath, + timeoutMs: settings.verificationCommandTimeoutMs ?? 300_000, + onHeartbeat: () => stuckDetector?.recordActivity(task.id), + }); + await this.store.finishTaskVerificationRequest(task.id, claimedVerification.requestId, verificationResult.success ? "passed" : "failed", { + success: verificationResult.success, exitCode: verificationResult.exitCode, + durationMs: Date.now() - startedAt, timedOut: verificationResult.timedOut ?? false, + stdoutTail: verificationResult.stdout.slice(-8_000), stderrTail: verificationResult.stderr.slice(-8_000), + }); + } catch (error) { + await this.store.finishTaskVerificationRequest(task.id, claimedVerification.requestId, "failed", undefined, error instanceof Error ? error.message.slice(0, 1_000) : "Verification runner failed"); + } + } finally { + verificationRequestInFlight = false; + } + }; + await runPendingTaskVerification(); + const customTools = [ this.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stepCheckpoints, stuckDetector), this.createTaskLogTool(task.id), @@ -12002,6 +12039,17 @@ export class TaskExecutor { lastEffectiveColumnAgentId: columnAgentSeam?.agent.id ?? null, }, worktreePath); + /* + FNXC:TaskVerificationRequest 2026-07-30-17:40: + A chat request can arrive after this executor session starts. Poll while + this task retains the live worktree so requested records are claimed by + their owner rather than waiting for an unrelated future dispatch. + */ + const verificationRequestTimer = setInterval(() => { + void runPendingTaskVerification().catch((error) => { + executorLog.warn(`${task.id}: verification request pickup failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }, 1_000); let leaseRenewalTimer: ReturnType | undefined; if (detail.assignedAgentId && detail.checkedOutBy === detail.assignedAgentId) { const leaseEpoch = detail.checkoutLeaseEpoch ?? 0; @@ -12638,6 +12686,7 @@ export class TaskExecutor { } } } finally { + clearInterval(verificationRequestTimer); if (leaseRenewalTimer) { clearInterval(leaseRenewalTimer); } diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index d355da6a08..2b72408a29 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -32,6 +32,8 @@ const PROVISIONING_TOOLS = ["fn_agent_create", "fn_agent_delete"] as const; */ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet = new Set([ "fn_run_verification", + // FNXC:TaskVerificationRequest 2026-07-30-00:00: queuing ultimately executes an executor-owned subprocess. + "fn_task_request_verification", "fn_acquire_repo_worktree", ]); @@ -151,6 +153,8 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_artifact_view", "fn_task_list", "fn_task_show", + // FNXC:TaskVerificationRequest 2026-07-30-00:00: persisted status is an explicit read-only operation. + "fn_task_verification_status", // FNXC:ToolGovernance 2026-06-27-14:16: Task search is a read-only duplicate-discovery tool; classify it positively so heartbeat/triage calls never rely on the unknown-tool exempt fallback. "fn_task_search", // FNXC:ToolGovernance 2026-06-27-00:00: `fn_task_get` is a deprecated recognition-only alias. It is no longer registered as a live tool, but historical/in-flight calls must still classify as read-only instead of falling through to unknown-tool handling. @@ -230,6 +234,7 @@ export const COORDINATION_EXEMPT_TOOLS = [ */ "fn_task_list", "fn_task_show", + "fn_task_verification_status", "fn_task_search", "fn_task_get", "fn_memory_search", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 39dfce2d62..eabfd1d407 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -469,7 +469,7 @@ export { type SquashAuditRecentMainCommit, } from "./merger-squash-audit.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js"; -export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; +export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, wrapToolsWithActionGate, type AgentOptions, type AgentResult } from "./pi.js"; export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp-resolution.js"; export { discoverMcpServers, type DiscoverMcpServersOptions, type DiscoverMcpServersResult } from "./mcp-discovery-service.js"; export { runtimeSupportsMcp, logMcpForwardingSkipped } from "./mcp-runtime-support.js";