fix(FN-7952): establish PostgreSQL core authority (#2108)

## Summary

Fusion’s core runtime now treats PostgreSQL as the authoritative
metadata store without leaving current CLI, dashboard, desktop, or
engine composition roots uncompilable between stack layers. This is the
99-file foundation for the larger cutover: subsequent PRs migrate the
remaining consumers, plugins, and operator surfaces.

## Design decisions

- Runtime store construction fails closed when an asynchronous
PostgreSQL layer is unavailable; SQLite remains readable only at
explicit migration and identity-recovery boundaries.
- Project ownership is enforced across active, archived, workflow,
mission, analytics, and plugin-schema data.
- The small set of cross-package files in this layer are
compatibility-critical call sites required for a green intermediate
commit, not the complete consumer migration.
- Schema migration 0008 remains assigned to session-advisor state from
current `main`; mission lineage idempotency advances to 0009 so neither
invariant can be skipped.

## Validation

- All affected package typechecks pass: Core, Engine, Dashboard, CLI,
and Desktop.
- `pnpm test:gate` passes: 478 tests across the engine gate, PostgreSQL
core gate, and CLI workflow shape.
- The PR changes exactly 99 files.

## Stack

This is the base PR. Engine/dashboard, CLI/desktop/ops, plugins, and
docs/release follow as stacked PRs, each below 100 changed files.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the standard runtime backend, with embedded
PostgreSQL enabled by default.
* Added project-scoped storage for tasks, archives, chat sessions,
missions, knowledge pages, and operational data.
* Improved archived-task search, filtering, pagination, and restoration.
* Added safer plugin schema initialization with validation and project
isolation.
* Added PostgreSQL-backed workflow, mission, validator, and dashboard
capabilities.

* **Bug Fixes**
  * Improved startup timeout cancellation and resource cleanup.
* Prevented cross-project data access and phantom reservation cleanup
errors.
* Ensured archived tasks remain read-only and asynchronous writes
complete reliably.
  * Retired SQLite opt-out settings with clear startup errors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-14 22:13:30 -07:00
committed by GitHub
parent e97081fb77
commit 2e4fcfcaea
99 changed files with 7026 additions and 5166 deletions

View File

@@ -185,22 +185,15 @@ describe("PluginRunner", () => {
expect(mockPluginLoader.loadAllPlugins).toHaveBeenCalled();
});
it("should execute schema init hooks after plugin load", async () => {
const schemaHook = vi.fn();
it("does not replay schema init hooks after PluginLoader initializes each plugin", async () => {
mockPluginLoader.getPluginSchemaInitHooks.mockReturnValue([
{ pluginId: "plugin-a", hook: schemaHook },
{ pluginId: "plugin-a", hook: vi.fn() },
]);
await pluginRunner.init();
expect(mockPluginLoader.getPluginSchemaInitHooks).toHaveBeenCalledTimes(1);
expect(mockTaskStore.getDatabase).toHaveBeenCalledTimes(1);
const db = mockTaskStore.getDatabase.mock.results[0]?.value as {
runPluginSchemaInits: ReturnType<typeof vi.fn>;
};
expect(db.runPluginSchemaInits).toHaveBeenCalledWith([
{ pluginId: "plugin-a", hook: schemaHook },
]);
expect(mockPluginLoader.getPluginSchemaInitHooks).not.toHaveBeenCalled();
expect(mockTaskStore.getDatabase).not.toHaveBeenCalled();
});
it("should skip schema init execution when no hooks are registered", async () => {

View File

@@ -7,8 +7,8 @@
* factory is the single place that actually instantiates the live bundle and
* stitches the seams:
*
* - Builds a {@link CliSessionStore} over the project's EXISTING core Database
* (never opens a second connection — the store is a thin query layer).
* - Builds a {@link CliSessionStore} over the project's existing PostgreSQL
* data layer (never opens a second connection).
* - Registers all bundled adapters into a fresh {@link CliAdapterRegistry} (a
* per-runtime registry, NOT the process-wide `defaultCliAdapterRegistry`, so
* multi-project boots never collide on duplicate-registration).
@@ -25,7 +25,7 @@
*/
import { CliSessionStore } from "@fusion/core";
import type { Database } from "@fusion/core";
import type { AsyncDataLayer } from "@fusion/core";
import { CliAdapterRegistry } from "./adapter.js";
import { BUNDLED_CLI_ADAPTERS } from "./adapters/index.js";
import { CliSessionManager, type CliSessionManagerOptions } from "./session-manager.js";
@@ -38,8 +38,8 @@ import type { CliAgentRuntime } from "../executor.js";
export interface CreateCliAgentRuntimeOptions {
/** The project's `.fusion` dir (scratch root for hook scripts). */
fusionDir: string;
/** The project's already-open core Database (reused, never re-opened). */
db: Database;
/** The project's already-open PostgreSQL data layer (reused, never re-opened). */
asyncLayer: AsyncDataLayer;
/** Project this runtime drives (`cli_sessions.projectId`). */
projectId: string;
/**
@@ -82,7 +82,7 @@ export interface BootstrappedCliAgentRuntime {
*/
isCliSessionWaitingOnInput: (taskId: string) => boolean;
/** Tear down the PTY manager (scoped SIGKILL of this runtime's PTYs only). */
dispose: () => void;
dispose: () => Promise<void>;
}
/**
@@ -90,13 +90,15 @@ export interface BootstrappedCliAgentRuntime {
* beyond the store's reads against the supplied Database; spawning a PTY or
* running recovery is the caller's job (`resumeCoordinator.recoverOnStart()`).
*/
export function createCliAgentRuntime(
export async function createCliAgentRuntime(
options: CreateCliAgentRuntimeOptions,
): BootstrappedCliAgentRuntime {
const { fusionDir, db, projectId, hookEndpointUrl } = options;
): Promise<BootstrappedCliAgentRuntime> {
const { asyncLayer, projectId, hookEndpointUrl } = options;
// 1. Store over the project's existing Database (thin query layer; no new conn).
const store = new CliSessionStore(fusionDir, db);
// FNXC:CliAgentPostgres 2026-07-14-12:00:
// Hydrate the project-scoped cache before state machines or recovery inspect
// it; mutations remain ordered through the shared PostgreSQL data layer.
const store = await CliSessionStore.create(asyncLayer, projectId);
// 2. A per-runtime registry with every bundled adapter (not the process-wide
// singleton — avoids duplicate-registration across multi-project boots).
@@ -163,8 +165,9 @@ export function createCliAgentRuntime(
return false;
}
},
dispose: () => {
dispose: async () => {
manager.dispose();
await store.flush();
},
};
}

View File

@@ -11,7 +11,7 @@ const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, lstatSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
@@ -1693,7 +1693,7 @@ export interface TaskExecutorOptions {
pluginRunner?: PluginRunner;
/** MessageStore for sending messages to other agents. When provided, executor agents gain fn_send_message capability. */
messageStore?: import("@fusion/core").MessageStore;
missionStore?: MissionStore;
missionStore?: MissionStore | AsyncMissionStore;
secretsStore?: Pick<import("@fusion/core").SecretsStore, "listEnvExportable">;
onSliceComplete?: (slice: Slice) => void;
onStart?: (task: Task, worktreePath: string) => void;
@@ -2344,7 +2344,9 @@ export class TaskExecutor {
private get approvalRequestStore(): ApprovalRequestStore {
if (!this._approvalRequestStore) {
const layer = this.store.getAsyncLayer();
this._approvalRequestStore = new ApprovalRequestStore(layer ? null : this.store.getDatabase(), { asyncLayer: layer });
if (!layer) throw new Error("Executor TaskStore is missing its PostgreSQL AsyncDataLayer");
/* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Runtime approval persistence is PostgreSQL-only; never reopen the removed project SQLite database when backend wiring is incomplete. */
this._approvalRequestStore = new ApprovalRequestStore(null, { asyncLayer: layer });
}
return this._approvalRequestStore;
}
@@ -6052,9 +6054,7 @@ export class TaskExecutor {
},
{ columnSequence: this.inferLegacyColumnSequence(live.column) },
);
const legacyAudit = typeof this.store.getRunAuditEvents === "function"
? this.store.getRunAuditEvents({ taskId })
: [];
const legacyAudit = await this.store.getRunAuditEventsAsync({ taskId });
await observeWorkflowParity({
settings,
@@ -13603,7 +13603,7 @@ export class TaskExecutor {
// FN-009: If worktree directory doesn't exist, skip git validation for task completion.
// This is safe because:
// 1. Task completion doesn't modify the worktree
// 2. Deliverables (task documents, follow-up tasks) are stored in fusion.db
// FNXC:PostgresRuntimeStorage 2026-07-14-18:47: Deliverables (task documents and follow-up tasks) are stored in the project-scoped PostgreSQL store.
// 3. If code changes were made, the worktree would exist
// 4. This prevents ENOENT errors when agents complete documentation/coordination tasks
if (!existsSync(worktreePath)) {
@@ -14583,7 +14583,7 @@ export class TaskExecutor {
}
if (branchDeleted) {
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
}
// Clear worktree tracking
@@ -17737,7 +17737,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
});
await this.store.logEntry(taskId, `Deleted branch`, branch);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
this.store.clearStaleExecutionStartBranchReferences([branch], taskId);
await this.store.clearStaleExecutionStartBranchReferences([branch], taskId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(`${taskId}: failed to delete conflicting branch ${branch}: ${msg}`);
@@ -17825,7 +17825,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
}
try {
await execAsync(`git branch -D "${branch}"`, { cwd: this.rootDir });
this.store.clearStaleExecutionStartBranchReferences([branch], taskId);
await this.store.clearStaleExecutionStartBranchReferences([branch], taskId);
} catch {
// best-effort — branch may not exist, which is fine for a stale-path cleanup
}
@@ -17874,7 +17874,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
});
await this.store.logEntry(taskId, `Removed stale branch`, branch);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
return true;
} catch (branchDeleteError: unknown) {
const branchDeleteErrorMessage = branchDeleteError instanceof Error ? branchDeleteError.message : String(branchDeleteError);
@@ -17893,7 +17893,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
});
await this.store.logEntry(taskId, `Force-removed stale branch reference via update-ref`, refPath);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ }
return true;
} catch (updateRefError: unknown) {
const updateRefErrorMessage = updateRefError instanceof Error ? updateRefError.message : String(updateRefError);

View File

@@ -10757,7 +10757,7 @@ export async function aiMergeTask(
// conflict-suffixed branch), null it so the dependent task doesn't
// hard-fail at worktree creation once this branch is gone.
try {
const cleared = store.clearStaleExecutionStartBranchReferences([branch], taskId);
const cleared = await store.clearStaleExecutionStartBranchReferences([branch], taskId);
if (cleared.length > 0) {
mergerLog.log(`${taskId}: cleared stale baseBranch on ${cleared.length} dependent task(s): ${cleared.join(", ")}`);
}

View File

@@ -166,7 +166,7 @@ export class MeshLeaseManager {
});
try {
const released = tryRelease();
const released = await tryRelease();
if (released.ok) {
return "released";
}
@@ -191,7 +191,7 @@ export class MeshLeaseManager {
} catch (_error) {
await new Promise((resolve) => setTimeout(resolve, 120));
try {
const released = tryRelease();
const released = await tryRelease();
if (released.ok) {
return "released";
}
@@ -235,7 +235,7 @@ export class MeshLeaseManager {
return false;
}
const claim = centralClaimStore.getTaskClaim(projectId, taskId);
const claim = await centralClaimStore.getTaskClaim(projectId, taskId);
const localHasOwner = Boolean(task.checkedOutBy || task.checkoutNodeId);
if (!claim && localHasOwner) {
@@ -262,7 +262,7 @@ export class MeshLeaseManager {
const renewedAtMs = Date.parse(claim.leaseRenewedAt);
const staleByTime = Number.isFinite(renewedAtMs) && Date.now() - renewedAtMs > staleCutoff;
if (status === "offline" || status === "error" || staleByTime) {
const released = centralClaimStore.releaseTaskClaim({
const released = await centralClaimStore.releaseTaskClaim({
projectId,
taskId,
nodeId: claim.ownerNodeId,

View File

@@ -15,6 +15,7 @@ import { EventEmitter } from "node:events";
import type {
TaskStore,
MissionStore,
AsyncMissionStore,
MissionContractAssertion,
MissionFeature,
MissionValidatorRun,
@@ -88,7 +89,7 @@ export interface MissionExecutionLoopOptions {
/** Task store for accessing task data */
taskStore: TaskStore;
/** Mission store for accessing mission/feature data */
missionStore: MissionStore;
missionStore: MissionStore | AsyncMissionStore;
/** Optional MissionAutopilot for notifying on loop state changes */
missionAutopilot?: {
notifyValidationComplete?: (featureId: string, status: "passed" | "failed" | "blocked" | "error") => void | Promise<void>;
@@ -114,7 +115,7 @@ export interface MissionExecutionLoopOptions {
export class MissionExecutionLoop extends EventEmitter {
private running = false;
private taskStore: TaskStore;
private missionStore: MissionStore;
private missionStore: MissionStore | AsyncMissionStore;
private rootDir: string;
private maxRetryBudget: number;
private missionAutopilot?: MissionExecutionLoopOptions["missionAutopilot"];
@@ -176,7 +177,7 @@ export class MissionExecutionLoop extends EventEmitter {
* terminated by maintenance while their session is still in-flight.
*/
async reapStaleValidatorRuns(maxAgeMs: number): Promise<{ reapedCount: number }> {
const staleRuns = this.missionStore.listStaleRunningValidatorRuns(maxAgeMs);
const staleRuns = await this.missionStore.listStaleRunningValidatorRuns(maxAgeMs);
let reapedCount = 0;
for (const run of staleRuns) {
@@ -185,15 +186,15 @@ export class MissionExecutionLoop extends EventEmitter {
}
try {
const reapedRun = this.missionStore.reapValidatorRun(
const reapedRun = await this.missionStore.reapValidatorRun(
run.id,
`Validator run reaped after exceeding stale threshold (${maxAgeMs}ms) without a live owner.`,
);
reapedCount += 1;
try {
const milestone = this.missionStore.getMilestone(reapedRun.milestoneId);
const missionId = milestone ? this.missionStore.getMission(milestone.missionId)?.id : undefined;
const milestone = await this.missionStore.getMilestone(reapedRun.milestoneId);
const missionId = milestone ? (await this.missionStore.getMission(milestone.missionId))?.id : undefined;
const elapsedMs = Math.max(0, Date.now() - new Date(run.startedAt).getTime());
void this.taskStore.recordRunAuditEvent({
agentId: "store",
@@ -238,7 +239,7 @@ export class MissionExecutionLoop extends EventEmitter {
}
try {
const missions = this.missionStore.listMissions();
const missions = await this.missionStore.listMissions();
let recoveredCount = 0;
for (const mission of missions) {
@@ -246,7 +247,7 @@ export class MissionExecutionLoop extends EventEmitter {
let hierarchy;
try {
hierarchy = this.missionStore.getMissionWithHierarchy(mission.id);
hierarchy = await this.missionStore.getMissionWithHierarchy(mission.id);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
loopLog.warn(`getMissionWithHierarchy failed for mission ${mission.id}: ${errorMessage} — skipping`);
@@ -260,7 +261,7 @@ export class MissionExecutionLoop extends EventEmitter {
for (const slice of milestone.slices) {
if (slice.status !== "active") continue;
const supersededFixes = this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
const supersededFixes = await this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
const supersededFeatureIds = new Set(supersededFixes.featureIds);
if (supersededFixes.supersededCount > 0) {
loopLog.warn(
@@ -270,7 +271,13 @@ export class MissionExecutionLoop extends EventEmitter {
recoveredCount += supersededFixes.supersededCount;
}
for (const feature of slice.features) {
/*
FNXC:PostgresMissionRecoveryPerformance 2026-07-14-17:55:
Superseded-fix reconciliation can change multiple feature states. Refresh the slice once and reuse that coherent snapshot throughout recovery instead of issuing getFeature for every implementing or stranded feature.
*/
const refreshedFeatures = await this.missionStore.listFeatures(slice.id);
const refreshedById = new Map(refreshedFeatures.map((feature) => [feature.id, feature]));
for (const feature of refreshedFeatures) {
if (supersededFeatureIds.has(feature.id)) {
continue;
}
@@ -316,7 +323,7 @@ export class MissionExecutionLoop extends EventEmitter {
// Features that remained implementing while their linked task already finished
// can be stranded after restart; recover by re-triggering task outcome.
if (feature.loopState === "implementing" && feature.taskId) {
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
const currentFeature = refreshedById.get(feature.id) ?? feature;
if (
this.activeValidations.has(feature.id)
|| currentFeature.loopState === "passed"
@@ -386,7 +393,7 @@ export class MissionExecutionLoop extends EventEmitter {
&& feature.lastValidatorStatus !== "passed"
&& !this.activeValidations.has(feature.id)
) {
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
const currentFeature = refreshedById.get(feature.id) ?? feature;
if (
currentFeature.loopState === "passed"
|| currentFeature.lastValidatorStatus === "passed"
@@ -437,7 +444,7 @@ export class MissionExecutionLoop extends EventEmitter {
try {
// Find the feature linked to this task
const feature = this.missionStore.getFeatureByTaskId(taskId);
const feature = await this.missionStore.getFeatureByTaskId(taskId);
if (!feature) {
loopLog.log(`Task ${taskId} has no linked feature; skipping validation`);
return;
@@ -447,10 +454,10 @@ export class MissionExecutionLoop extends EventEmitter {
// recoverActiveMissions guard. A parked/blocked/completed mission must
// not keep minting validations (and Fix features) for completed tasks.
// Features that don't resolve to a mission keep the current behavior.
const mission = this.resolveFeatureMission(feature);
const mission = await this.resolveFeatureMission(feature);
if (mission && mission.status !== "active") {
loopLog.log(`Feature ${feature.id} belongs to mission ${mission.id} with status "${mission.status}"; skipping validation`);
this.logFeatureWarningEvent(feature.id, "validation_skipped_mission_inactive", `Validation skipped: mission ${mission.id} status is "${mission.status}" (expected "active").`, {
await this.logFeatureWarningEvent(feature.id, "validation_skipped_mission_inactive", `Validation skipped: mission ${mission.id} status is "${mission.status}" (expected "active").`, {
taskId,
missionId: mission.id,
missionStatus: mission.status,
@@ -459,14 +466,14 @@ export class MissionExecutionLoop extends EventEmitter {
}
if (feature.loopState === "needs_fix") {
this.missionStore.transitionLoopState(feature.id, "implementing");
await this.missionStore.transitionLoopState(feature.id, "implementing");
feature.loopState = "implementing";
}
// Only validate features in "implementing" state
if (feature.loopState !== "implementing") {
loopLog.log(`Feature ${feature.id} loopState is "${feature.loopState}"; skipping validation`);
this.logFeatureWarningEvent(feature.id, "validation_skipped_loop_state", `Validation skipped: feature ${feature.id} is in loopState "${feature.loopState}" (expected "implementing").`, {
await this.logFeatureWarningEvent(feature.id, "validation_skipped_loop_state", `Validation skipped: feature ${feature.id} is in loopState "${feature.loopState}" (expected "implementing").`, {
taskId,
loopState: feature.loopState,
});
@@ -475,7 +482,7 @@ export class MissionExecutionLoop extends EventEmitter {
if (this.activeValidations.has(feature.id)) {
loopLog.log(`Feature ${feature.id} already has an active validation; skipping duplicate trigger`);
this.logFeatureWarningEvent(feature.id, "validation_deduplicated", `Validation already running for feature ${feature.id}; duplicate trigger ignored.`, {
await this.logFeatureWarningEvent(feature.id, "validation_deduplicated", `Validation already running for feature ${feature.id}; duplicate trigger ignored.`, {
taskId,
});
return;
@@ -500,10 +507,10 @@ export class MissionExecutionLoop extends EventEmitter {
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
// Lazily guarantee a linked assertion before validation so every feature
// is evaluated by the validator even when legacy data is missing links.
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
let assertions = await this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
assertions = await this.missionStore.ensureFeatureAssertionLinked(feature.id);
}
// Mark feature as being validated
@@ -513,7 +520,7 @@ export class MissionExecutionLoop extends EventEmitter {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// Start the validator run (no board task per docs/missions.md)
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
const run = await this.missionStore.startValidatorRun(feature.id, "task_completion");
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
// Run the validation
@@ -622,7 +629,7 @@ export class MissionExecutionLoop extends EventEmitter {
): Promise<ValidationResult> {
loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`);
const milestone = this.resolveFeatureMilestone(feature);
const milestone = await this.resolveFeatureMilestone(feature);
// Build the validation prompt
const prompt = this.buildValidationPrompt(feature, assertions, milestone);
@@ -1288,8 +1295,8 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
return lines.join("\n");
}
private resolveFeatureMilestone(feature: MissionFeature): Milestone | undefined {
const slice = this.missionStore.getSlice(feature.sliceId);
private async resolveFeatureMilestone(feature: MissionFeature): Promise<Milestone | undefined> {
const slice = await this.missionStore.getSlice(feature.sliceId);
if (!slice) {
return undefined;
}
@@ -1297,8 +1304,8 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
return this.missionStore.getMilestone(slice.milestoneId);
}
private resolveFeatureMission(feature: MissionFeature): Mission | undefined {
const milestone = this.resolveFeatureMilestone(feature);
private async resolveFeatureMission(feature: MissionFeature): Promise<Mission | undefined> {
const milestone = await this.resolveFeatureMilestone(feature);
if (!milestone) {
return undefined;
}
@@ -1306,27 +1313,27 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
return this.missionStore.getMission(milestone.missionId);
}
private completeValidatorRunIfStillRunning(
private async completeValidatorRunIfStillRunning(
runId: string | undefined,
status: "passed" | "failed" | "blocked" | "error",
summaryOrReason?: string,
): boolean {
): Promise<boolean> {
if (!runId) {
return false;
}
if (typeof this.missionStore.getValidatorRun !== "function") {
this.missionStore.completeValidatorRun(runId, status, summaryOrReason);
await this.missionStore.completeValidatorRun(runId, status, summaryOrReason);
return true;
}
const run = this.missionStore.getValidatorRun(runId);
const run = await this.missionStore.getValidatorRun(runId);
if (!run || run.status !== "running") {
loopLog.warn(`Validator run ${runId} is no longer running; skipping ${status} completion.`);
return false;
}
this.missionStore.completeValidatorRun(runId, status, summaryOrReason);
await this.missionStore.completeValidatorRun(runId, status, summaryOrReason);
return true;
}
@@ -1339,11 +1346,11 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
summary: string,
): Promise<void> {
try {
this.completeValidatorRunIfStillRunning(runId, "passed", summary);
await this.completeValidatorRunIfStillRunning(runId, "passed", summary);
const feature = this.missionStore.getFeature(featureId);
const feature = await this.missionStore.getFeature(featureId);
if (feature && feature.status !== "done") {
this.missionStore.updateFeatureStatus(featureId, "done");
await this.missionStore.updateFeatureStatus(featureId, "done");
}
loopLog.log(`Feature ${featureId} passed validation`);
@@ -1383,15 +1390,13 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
actual: a.actual,
}));
const canCompleteRun = runId
? typeof this.missionStore.getValidatorRun !== "function" || this.missionStore.getValidatorRun(runId)?.status === "running"
: false;
const canCompleteRun = runId ? (await this.missionStore.getValidatorRun(runId))?.status === "running" : false;
if (runId && failures.length > 0 && canCompleteRun) {
this.missionStore.recordValidatorFailures(runId, failures);
await this.missionStore.recordValidatorFailures(runId, failures);
}
this.completeValidatorRunIfStillRunning(runId, "failed", result.summary);
await this.completeValidatorRunIfStillRunning(runId, "failed", result.summary);
loopLog.log(`Feature ${featureId} failed validation with ${failures.length} failures`);
@@ -1401,7 +1406,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
// R16 — durable observability: a verification/validation failure is a
// persisted mission event, not just a log line.
this.logFeatureMissionEvent(featureId, "error", "validation_failed", `Validation failed for feature ${featureId}: ${result.summary}`, {
await this.logFeatureMissionEvent(featureId, "error", "validation_failed", `Validation failed for feature ${featureId}: ${result.summary}`, {
runId: runId ?? null,
failedAssertionIds: failures.map((f) => f.assertionId),
reason: failureReason,
@@ -1410,7 +1415,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
// Create fix feature
try {
const fixFeature = this.missionStore.createGeneratedFixFeature(
const fixFeature = await this.missionStore.createGeneratedFixFeature(
featureId,
runId || "unknown",
failures.map((f) => f.assertionId),
@@ -1429,7 +1434,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
// logged. The branch-group-collision learning: silent triage stalls
// are invisible mission deadlocks. The Fix Feature was created and can
// be triaged manually, so we continue, but the failure is persisted.
this.logFeatureMissionEvent(featureId, "error", "fix_feature_triage_failed", `Auto-triage of fix feature ${fixFeature.id} failed: ${triageMessage}`, {
await this.logFeatureMissionEvent(featureId, "error", "fix_feature_triage_failed", `Auto-triage of fix feature ${fixFeature.id} failed: ${triageMessage}`, {
runId: runId ?? null,
fixFeatureId: fixFeature.id,
error: triageMessage,
@@ -1448,14 +1453,14 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
loopLog.warn(`Feature ${featureId} retry budget exhausted; marking as blocked`);
// completeValidatorRun already handles the blocked transition when budget is exhausted
terminalStatus = "blocked";
this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, {
await this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, {
runId: runId ?? null,
});
this.emit("validation:budget_exhausted", { featureId, runId });
} else {
loopLog.error(`Error creating fix feature for ${featureId}:`, message);
// R16 — a swallowed Fix-Feature creation error is durably recorded.
this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_failed", `Failed to create fix feature for ${featureId}: ${message}`, {
await this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_failed", `Failed to create fix feature for ${featureId}: ${message}`, {
runId: runId ?? null,
error: message,
});
@@ -1517,13 +1522,13 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
reason: string | undefined,
): Promise<void> {
try {
this.completeValidatorRunIfStillRunning(runId, "blocked", reason);
await this.completeValidatorRunIfStillRunning(runId, "blocked", reason);
loopLog.warn(`Feature ${featureId} verification inconclusive: ${reason ?? "no reason provided"}`);
// R16/R21 — durable, distinguishable infra-failure event. The `outcome`
// marker separates infra-driven non-passes from real behavioral fails so
// the infra-failure rate can be tracked without conflating the two.
this.logFeatureMissionEvent(featureId, "warning", "verification_inconclusive", `Verification inconclusive for feature ${featureId}: ${reason ?? "verification could not conclude"}`, {
await this.logFeatureMissionEvent(featureId, "warning", "verification_inconclusive", `Verification inconclusive for feature ${featureId}: ${reason ?? "verification could not conclude"}`, {
runId: runId ?? null,
reason: reason ?? null,
outcome: "inconclusive",
@@ -1552,9 +1557,9 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
blockedReason: string | undefined,
): Promise<void> {
try {
this.completeValidatorRunIfStillRunning(runId, "blocked", blockedReason);
await this.completeValidatorRunIfStillRunning(runId, "blocked", blockedReason);
loopLog.log(`Feature ${featureId} blocked: ${blockedReason}`);
this.logFeatureErrorEvent(featureId, "validation_blocked", `Validation blocked for feature ${featureId}: ${blockedReason ?? "no reason provided"}`, {
await this.logFeatureErrorEvent(featureId, "validation_blocked", `Validation blocked for feature ${featureId}: ${blockedReason ?? "no reason provided"}`, {
runId,
blockedReason: blockedReason ?? null,
});
@@ -1579,9 +1584,9 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
error: string,
): Promise<void> {
try {
this.completeValidatorRunIfStillRunning(runId, "error", error);
await this.completeValidatorRunIfStillRunning(runId, "error", error);
loopLog.error(`Feature ${featureId} validation error: ${error}`);
this.logFeatureErrorEvent(featureId, "validation_error", `Validation error for feature ${featureId}: ${error}`, {
await this.logFeatureErrorEvent(featureId, "validation_error", `Validation error for feature ${featureId}: ${error}`, {
runId,
error,
});
@@ -1597,40 +1602,39 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
}
}
private logFeatureWarningEvent(
private async logFeatureWarningEvent(
featureId: string,
code: string,
description: string,
metadata: Record<string, unknown>,
): void {
this.logFeatureMissionEvent(featureId, "warning", code, description, metadata);
): Promise<void> {
await this.logFeatureMissionEvent(featureId, "warning", code, description, metadata);
}
private logFeatureErrorEvent(
private async logFeatureErrorEvent(
featureId: string,
code: string,
description: string,
metadata: Record<string, unknown>,
): void {
this.logFeatureMissionEvent(featureId, "error", code, description, metadata);
): Promise<void> {
await this.logFeatureMissionEvent(featureId, "error", code, description, metadata);
}
private logFeatureMissionEvent(
private async logFeatureMissionEvent(
featureId: string,
eventType: "warning" | "error",
code: string,
description: string,
metadata: Record<string, unknown>,
): void {
const feature = this.missionStore.getFeature(featureId);
if (!feature) return;
const slice = this.missionStore.getSlice(feature.sliceId);
if (!slice) return;
const milestone = this.missionStore.getMilestone(slice.milestoneId);
if (!milestone) return;
): Promise<void> {
try {
this.missionStore.logMissionEvent?.(milestone.missionId, eventType, description, {
const feature = await this.missionStore.getFeature(featureId);
if (!feature) return;
const slice = await this.missionStore.getSlice(feature.sliceId);
if (!slice) return;
const milestone = await this.missionStore.getMilestone(slice.milestoneId);
if (!milestone) return;
await this.missionStore.logMissionEvent?.(milestone.missionId, eventType, description, {
code,
featureId,
sliceId: slice.id,

View File

@@ -239,30 +239,10 @@ export class PluginRunner {
const result = await this.options.pluginLoader.loadAllPlugins();
executorLog.log(`PluginRunner loaded ${result.loaded} plugins (${result.errors} errors)`);
// Execute onSchemaInit hooks from loaded plugins.
const schemaInitHooks = this.options.pluginLoader.getPluginSchemaInitHooks();
if (schemaInitHooks.length > 0) {
executorLog.log(`Executing onSchemaInit hooks from ${schemaInitHooks.length} plugins`);
try {
/*
* FNXC:PostgresCutover 2026-07-04:
* Skip the SQLite-specific runPluginSchemaInits path in backend mode.
* PostgreSQL uses Drizzle migrations for schema management. Matches the
* daemon.ts / dashboard.ts / serve.ts convention. Previously
* getDatabase() threw in backend mode and the catch swallowed it, so
* plugin onSchemaInit hooks silently never ran.
*/
if (this.options.taskStore.isBackendMode()) {
executorLog.log("onSchemaInit skipped — backend mode (PostgreSQL Drizzle migrations)");
} else {
const db = this.options.taskStore.getDatabase();
await db.runPluginSchemaInits(schemaInitHooks);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
executorLog.log(`onSchemaInit execution failed: ${message}`);
}
}
/*
FNXC:PluginPostgresSchema 2026-07-14-21:48:
PluginLoader completes each plugin's backend-specific schema initialization before loadAllPlugins counts it as loaded. PluginRunner must not replay the accumulated contracts after loading.
*/
// Subscribe to store events for task lifecycle hooks
this.subscribeToStoreEvents();

View File

@@ -15,7 +15,7 @@ import type {
CliSession,
NotificationPayload,
} from "@fusion/core";
import { ChatStore, createCentralDatabase, isEphemeralAgent, MissionStore } from "@fusion/core";
import { AsyncCentralClaimStore, ChatStore, isEphemeralAgent } from "@fusion/core";
import { Scheduler } from "../scheduler.js";
import type { PrMonitor, PrComment } from "../pr-monitor.js";
import type { PrInfo } from "@fusion/core";
@@ -209,7 +209,7 @@ export class InProcessRuntime
private usageLimitPauser?: UsageLimitPauser;
private selfHealingManager?: SelfHealingManager;
private leaseManager?: MeshLeaseManager;
private leaseCentralClaimStore?: ReturnType<typeof createCentralDatabase>;
private leaseCentralClaimStore?: AsyncCentralClaimStore;
private agentStore?: AgentStore;
private heartbeatMonitor?: HeartbeatMonitor;
private triggerScheduler?: HeartbeatTriggerScheduler;
@@ -293,7 +293,6 @@ export class InProcessRuntime
try {
// 1. Initialize TaskStore (use external if provided, otherwise create new)
const {
TaskStore,
PluginStore: PluginStoreClass,
PluginLoader: PluginLoaderClass,
MessageStore: MessageStoreClass,
@@ -301,9 +300,7 @@ export class InProcessRuntime
// createTaskStoreForBackend is the startup factory that boots a
// PostgreSQL-backed TaskStore. Post default-flip: it boots embedded PG
// by default when DATABASE_URL is unset (the zero-config production
// path), external PG when DATABASE_URL is set, and returns null only
// when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy
// SQLite). The engine is the primary construction site for `fn serve`
// path) and external PG when DATABASE_URL is set. The engine is the primary construction site for `fn serve`
// / dashboard: every project's TaskStore flows through
// InProcessRuntime.start(). When the factory returns a backend result,
// the engine owns the result's shutdown() for process teardown.
@@ -317,17 +314,13 @@ export class InProcessRuntime
rootDir: this.config.workingDirectory,
projectId: this.config.projectId,
});
if (backendBoot) {
this.taskStore = backendBoot.taskStore;
this.backendShutdown = backendBoot.shutdown;
runtimeLog.log(
`TaskStore initialized on PostgreSQL (${backendBoot.backend.mode}) for project ${this.config.projectId}`,
);
} else {
this.taskStore = new TaskStore(this.config.workingDirectory);
await this.taskStore.init();
runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`);
}
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Engine runtimes must fail
// startup when PostgreSQL cannot boot; constructing a SQLite TaskStore is no longer valid.
this.taskStore = backendBoot.taskStore;
this.backendShutdown = backendBoot.shutdown;
runtimeLog.log(
`TaskStore initialized on PostgreSQL (${backendBoot.backend.mode}) for project ${this.config.projectId}`,
);
}
// Initialize MessageStore early so TaskExecutor receives send_message capability.
@@ -335,6 +328,12 @@ export class InProcessRuntime
// In backend mode, pass the AsyncDataLayer so MessageStore delegates to the
// async helpers; otherwise pass the sync SQLite Database (legacy path).
const messageLayer = this.taskStore.getAsyncLayer();
if (!messageLayer) {
throw new Error("PostgreSQL TaskStore did not expose its AsyncDataLayer");
}
// FNXC:PostgresMeshClaims 2026-07-14-17:31: Cross-node checkout and
// recovery share the central.task_claims table through the project pool.
this.leaseCentralClaimStore = new AsyncCentralClaimStore(messageLayer);
// FNXC:CentralCore 2026-06-26-13:30:
// In backend mode, attach the TaskStore's AsyncDataLayer to the shared
@@ -354,11 +353,7 @@ export class InProcessRuntime
}
}
if (messageLayer) {
this.messageStore = new MessageStoreClass(null, { asyncLayer: messageLayer });
} else {
this.messageStore = new MessageStoreClass(this.taskStore.getDatabase());
}
this.messageStore = new MessageStoreClass(null, { asyncLayer: messageLayer });
await yieldEventLoop();
@@ -367,9 +362,10 @@ export class InProcessRuntime
// In backend mode, pass the AsyncDataLayer so PluginStore delegates to the
// async helpers; otherwise use the legacy SQLite path.
const pluginLayer = this.taskStore.getAsyncLayer();
this.pluginStore = pluginLayer
? new PluginStoreClass(this.config.workingDirectory, { asyncLayer: pluginLayer })
: new PluginStoreClass(this.config.workingDirectory);
if (!pluginLayer) {
throw new Error("PostgreSQL TaskStore did not expose the plugin AsyncDataLayer");
}
this.pluginStore = new PluginStoreClass(this.config.workingDirectory, { asyncLayer: pluginLayer });
await this.pluginStore.init();
this.pluginLoader = new PluginLoaderClass({
@@ -472,6 +468,8 @@ export class InProcessRuntime
agentStoreForReflection = new AgentStoreClass({
rootDir: this.taskStore.getFusionDir(),
taskStore: this.taskStore,
claimStore: this.leaseCentralClaimStore,
projectId: this.config.projectId,
...(agentLayer ? { asyncLayer: agentLayer } : {}),
});
await agentStoreForReflection.init();
@@ -502,22 +500,19 @@ export class InProcessRuntime
// 5. Initialize Scheduler
/*
* FNXC:SqliteFinalRemoval 2026-06-24-15:55:
* In backend mode (PostgreSQL), getMissionStore() throws because the
* MissionStore has not been converted to async yet. Catch the error and
* degrade gracefully: mission autopilot and mission execution loop are
* disabled until the MissionStore is fully converted to the async path.
* FNXC:PostgresMissionRuntime 2026-07-14-17:20:
* Resolve one mission store for autopilot, scheduler, and validator-loop
* behavior. Each consumer awaits the sync-or-async union, so backend mode
* no longer disables mission execution or feature reconciliation.
*/
let missionStore: import("@fusion/core").MissionStore | undefined;
let missionStore:
| import("@fusion/core").MissionStore
| import("@fusion/core").AsyncMissionStore
| undefined;
// FNXC:MissionStore 2026-06-28-12:45:
// MissionAutopilot's STORE-access path was ported to drive BOTH backends —
// it types its store as `MissionStore | AsyncMissionStore` and awaits every
// call (mirrors the ResearchOrchestrator union+await port). So the autopilot
// is constructed from `autopilotMissionStore`, resolved in BOTH backends with
// NO `instanceof MissionStore` gate; the autopilot LOOP (watch/recover/
// recompute/persist) now runs in PG mode. The sync-only `missionStore` below
// stays gated for the Scheduler + MissionExecutionLoop, whose slice EXECUTION
// and validator-loop paths are NOT yet ported to async (out of scope).
// MissionAutopilot, Scheduler, and MissionExecutionLoop all await the
// MissionStore | AsyncMissionStore contract, so one resolved instance
// drives every mission lifecycle surface in both backends.
let autopilotMissionStore:
| import("@fusion/core").MissionStore
| import("@fusion/core").AsyncMissionStore
@@ -526,9 +521,10 @@ export class InProcessRuntime
const resolvedMissionStore = this.taskStore.getMissionStore();
// Union store for the autopilot — works in both SQLite and PG backends.
autopilotMissionStore = resolvedMissionStore;
// Sync-only narrowing for the Scheduler + MissionExecutionLoop, which still
// call the store synchronously and are skipped in PG backend mode.
missionStore = resolvedMissionStore instanceof MissionStore ? resolvedMissionStore : undefined;
// FNXC:PostgresMissionRuntime 2026-07-14-17:20:
// Scheduler and validator execution await the MissionStore union, so
// PostgreSQL receives the same mission recovery/validation lifecycle.
missionStore = resolvedMissionStore;
} catch (msErr) {
runtimeLog.warn(
`MissionStore unavailable (${this.taskStore.isBackendMode() ? "backend mode" : "init error"}); mission autopilot disabled:`,
@@ -551,15 +547,15 @@ export class InProcessRuntime
? {
notifyValidationComplete: async (featureId: string) => {
// Pass the feature's linked taskId to handleTaskCompletion, not the featureId
const feature = missionStore.getFeature(featureId);
const feature = await missionStore.getFeature(featureId);
if (!feature?.taskId) {
return;
}
const slice = missionStore.getSlice(feature.sliceId);
const milestone = slice ? missionStore.getMilestone(slice.milestoneId) : undefined;
const slice = await missionStore.getSlice(feature.sliceId);
const milestone = slice ? await missionStore.getMilestone(slice.milestoneId) : undefined;
const missionId = milestone?.missionId;
if (missionId) {
const mission = missionStore.getMission(missionId);
const mission = await missionStore.getMission(missionId);
if (mission?.autopilotEnabled && !missionAutopilot.isWatching(missionId)) {
missionAutopilot.watchMission(missionId);
}
@@ -574,29 +570,6 @@ export class InProcessRuntime
})
: undefined;
// FN-4823/FN-4819 §2.5: central-claim-aware recovery when central DB is reachable;
// fallback to local-only recovery remains in MeshLeaseManager for single-node contexts.
//
// FNXC:CentralCore 2026-06-26-13:00:
// In backend mode (PostgreSQL), do NOT construct the legacy SQLite
// CentralDatabase for mesh lease recovery. The sync CentralClaimStore
// contract cannot be satisfied by the async PostgreSQL helpers without a
// blocking bridge, and the single-node embedded-PG default does not need
// cross-node claim coordination. MeshLeaseManager falls back to its
// local-only recovery path (the centralClaimStore=undefined guard). The
// SQLite path remains for FUSION_NO_EMBEDDED_PG (legacy) mode.
if (this.taskStore.isBackendMode()) {
this.leaseCentralClaimStore = undefined;
} else {
try {
this.leaseCentralClaimStore = createCentralDatabase(this.centralCore.getGlobalDir());
this.leaseCentralClaimStore.init();
} catch (error) {
runtimeLog.warn(`Failed to initialize central claim store for mesh lease recovery: ${error instanceof Error ? error.message : String(error)}`);
this.leaseCentralClaimStore = undefined;
}
}
this.leaseManager = new MeshLeaseManager({
taskStore: this.taskStore,
agentStore: this.agentStore,
@@ -639,16 +612,22 @@ export class InProcessRuntime
await yieldEventLoop();
// 5a-cli. Initialize the CLI Agent Executor runtime (behind the
// `cliAgentExecutor` experimental flag). Reuses the project's existing core
// Database; predicates feed the self-healing + stuck-task seams below.
if (isExperimentalFeatureEnabled(settings, "cliAgentExecutor") && !this.taskStore.isBackendMode()) {
// FNXC:RuntimeSatelliteAsync 2026-06-24-14:00:
// CLI Agent Executor runtime requires the sync SQLite Database; skip in
// backend mode (the feature is experimental and not yet ported to async).
// `cliAgentExecutor` experimental flag). Reuses the project's existing
// PostgreSQL data layer; predicates feed self-healing + stuck-task seams.
if (isExperimentalFeatureEnabled(settings, "cliAgentExecutor")) {
/*
* FNXC:CliAgentPostgres 2026-07-14-12:00:
* The experimental executor is a supported PostgreSQL runtime surface;
* enabling it must not silently disable sessions after the cutover.
*/
try {
this.cliAgentRuntime = createCliAgentRuntime({
const asyncLayer = this.taskStore.getAsyncLayer();
if (!asyncLayer) {
throw new Error("CLI Agent Executor requires the PostgreSQL data layer");
}
this.cliAgentRuntime = await createCliAgentRuntime({
fusionDir: this.taskStore.getFusionDir(),
db: this.taskStore.getDatabase(),
asyncLayer,
projectId: this.config.projectId,
hookEndpointUrl: this.resolveCliAgentHookEndpointUrl(),
onNotification: (info) => {
@@ -834,15 +813,10 @@ export class InProcessRuntime
// Already started — nothing to do
}
if (!this.heartbeatMonitor && this.agentStore) {
// FNXC:RuntimeSatelliteAsync 2026-06-24-21:40:
// ChatStore now supports dual-path: in backend mode it uses the
// AsyncDataLayer; in SQLite mode it uses the sync Database.
const chatLayer = this.taskStore.getAsyncLayer();
this.chatStore ??= new ChatStore(
this.taskStore.getFusionDir(),
chatLayer ? null : this.taskStore.getDatabase(),
{ asyncLayer: chatLayer },
);
if (!chatLayer) throw new Error("Heartbeat ChatStore requires the project PostgreSQL AsyncDataLayer");
/* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Engine chat services share the authoritative project PostgreSQL layer and never reopen SQLite. */
this.chatStore ??= new ChatStore(chatLayer);
this.heartbeatMonitor = new HeartbeatMonitor({
store: this.agentStore,
agentStore: this.agentStore, // enables per-agent config resolution
@@ -1054,15 +1028,10 @@ export class InProcessRuntime
await yieldEventLoop();
// 7. Initialize SelfHealingManager
// FNXC:RuntimeSatelliteAsync 2026-06-24-21:42:
// ChatStore dual-path: use async layer in backend mode, sync DB otherwise.
{
const chatLayer2 = this.taskStore.getAsyncLayer();
this.chatStore ??= new ChatStore(
this.taskStore.getFusionDir(),
chatLayer2 ? null : this.taskStore.getDatabase(),
{ asyncLayer: chatLayer2 },
);
if (!chatLayer2) throw new Error("Self-healing ChatStore requires the project PostgreSQL AsyncDataLayer");
this.chatStore ??= new ChatStore(chatLayer2);
}
this.selfHealingManager = new SelfHealingManager(this.taskStore, {
rootDir: this.config.workingDirectory,
@@ -1170,17 +1139,18 @@ export class InProcessRuntime
// Mission crash recovery: restore autopilot state for missions that were active before crash
/*
* FNXC:SqliteFinalRemoval 2026-06-24-16:00:
* In backend mode, getMissionStore() throws (MissionStore not yet async).
* Wrap in try/catch to degrade gracefully — mission crash recovery is
* skipped, same as mission autopilot above.
* FNXC:PostgresMissionRuntime 2026-07-14-17:20:
* Crash recovery and scheduler reconciliation use the same union store in
* both backends; initialization errors remain fail-soft for engine boot.
*/
let activeMissionStore: import("@fusion/core").MissionStore | undefined;
let activeMissionStore:
| import("@fusion/core").MissionStore
| import("@fusion/core").AsyncMissionStore
| undefined;
// FNXC:MissionStore 2026-06-28-12:45: autopilot crash-recovery now runs in BOTH
// backends. `recoverMissions` accepts the `MissionStore | AsyncMissionStore`
// union and awaits every store call, so resolve the store WITHOUT an instanceof
// gate here. The sync-only `activeMissionStore` below still gates the
// scheduler-driven `reconcileAllMissionFeatures` (not yet ported to async).
// gate here. Scheduler reconciliation now awaits that same union.
let activeAutopilotMissionStore:
| import("@fusion/core").MissionStore
| import("@fusion/core").AsyncMissionStore
@@ -1188,12 +1158,7 @@ export class InProcessRuntime
try {
const resolvedActive = this.taskStore.getMissionStore();
activeAutopilotMissionStore = resolvedActive;
activeMissionStore = resolvedActive instanceof MissionStore ? resolvedActive : undefined;
// FNXC:MissionStore 2026-06-27-16:30 (review): log the PG-mode degrade for the
// scheduler-driven feature reconciliation that stays sync-only.
if (!activeMissionStore) {
runtimeLog.warn("[runtime] scheduler feature reconciliation skipped: sync MissionStore not available in PG backend mode");
}
activeMissionStore = resolvedActive;
} catch (error) {
activeMissionStore = undefined;
activeAutopilotMissionStore = undefined;
@@ -1242,6 +1207,17 @@ export class InProcessRuntime
runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
/*
FNXC:RuntimeStartupWiring 2026-07-14-18:18:
A failed partial startup must unwind every initialized subsystem and the owned PostgreSQL backend before surfacing the original startup error. The normal stop path is deliberately safe against partially initialized fields.
*/
try {
await this.stop();
} catch (cleanupError) {
runtimeLog.warn(
`Failed to fully unwind partial runtime startup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
);
}
this.setStatus("errored");
runtimeLog.error(`Failed to start InProcessRuntime:`, err.message);
this.emit("error", err);
@@ -1276,6 +1252,13 @@ export class InProcessRuntime
this.setStatus("stopping");
runtimeLog.log(`Stopping InProcessRuntime for project ${this.config.projectId}`);
/*
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
Runtime shutdown owns the startup-factory backend handle. Capture and clear it before any subsystem cleanup so concurrent/retried stop calls cannot invoke it twice, then release it from finally even when settings, plugins, or worktree cleanup fails. The first subsystem error remains the observable stop failure; backend cleanup is best-effort and never masks it.
*/
const backendShutdown = this.backendShutdown;
this.backendShutdown = undefined;
let stopError: Error | undefined;
try {
// 1. Remove concurrency change listener (if we registered one)
if (this.concurrencyChangedListener && typeof this.centralCore.off === "function") {
@@ -1293,7 +1276,7 @@ export class InProcessRuntime
// runtime's own PTYs only — never the dashboard / port 4040).
if (this.cliAgentRuntime) {
try {
this.cliAgentRuntime.dispose();
await this.cliAgentRuntime.dispose();
runtimeLog.log("CLI Agent Executor runtime disposed");
} catch (cliErr) {
runtimeLog.warn(
@@ -1452,39 +1435,28 @@ export class InProcessRuntime
}
}
if (this.leaseCentralClaimStore) {
this.leaseCentralClaimStore.close();
this.leaseCentralClaimStore = undefined;
}
// FNXC:RuntimeStartupWiring 2026-06-24-10:00:
// When the runtime booted a PostgreSQL-backed TaskStore via
// createTaskStoreForBackend, release the connection pool and stop the
// embedded PostgreSQL process (if one was started) now that every
// subsystem has drained. Best-effort: a failure is logged but does not
// mask the (already-clean) stop. On the legacy SQLite path this is a
// no-op (backendShutdown is undefined and the TaskStore closes its own
// SQLite database lazily).
if (this.backendShutdown) {
try {
await this.backendShutdown();
} catch (err) {
runtimeLog.warn(
`Backend shutdown failed: ${err instanceof Error ? err.message : err}`,
);
}
this.backendShutdown = undefined;
}
this.leaseCentralClaimStore = undefined;
this.setStatus("stopped");
runtimeLog.log(`InProcessRuntime stopped for project ${this.config.projectId}`);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
stopError = err;
this.setStatus("errored");
runtimeLog.error(`Error during shutdown:`, err.message);
this.emit("error", err);
throw err;
} finally {
if (backendShutdown) {
try {
await backendShutdown();
} catch (err) {
runtimeLog.warn(
`Backend shutdown failed: ${err instanceof Error ? err.message : err}`,
);
}
}
}
if (stopError) throw stopError;
}
/**

View File

@@ -9,6 +9,7 @@ import {
type TaskStore,
type Task,
type MissionStore,
type AsyncMissionStore,
type MissionFeature,
type PrInfo,
type AgentStore,
@@ -517,7 +518,7 @@ export interface SchedulerOptions {
/** Optional PR monitor for tracking in-review PRs */
prMonitor?: PrMonitor;
/** Optional MissionStore for slice activation and auto-advance */
missionStore?: MissionStore;
missionStore?: MissionStore | AsyncMissionStore;
/** Optional lease manager used to recover stale checkout leases before scheduling. */
leaseManager?: MeshLeaseManager;
/** Optional MissionAutopilot for autonomous mission progression */
@@ -725,7 +726,6 @@ export class Scheduler {
const settings = await this.store.getSettings();
if (!settings.globalPause && !settings.enginePaused) {
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
for (const dependent of todoTasks) {
const mentionsCompletedTask = dependent.dependencies.includes(task.id);
const currentlyBlockedByCompletedTask = dependent.blockedBy === task.id;
@@ -734,9 +734,16 @@ export class Scheduler {
const markerAcceptedByTaskId = settings.mergeRequestContractShadowEnabled === true
? new Map(await Promise.all(dependent.dependencies.map(async (depId) => [depId, (await this.store.getCompletionHandoffAcceptedMarker(depId)) !== null] as const)))
: undefined;
/*
FNXC:SchedulerArchiveReads 2026-07-14-19:10:
Dependency reconciliation is event-scoped. Resolve only the dependent's referenced IDs so one completed task cannot make the scheduler download and parse the entire cold archive.
*/
const dependencyTasks = (await Promise.all(
dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)),
)).filter((candidate) => candidate !== null);
const unresolvedDeps = getUnmetSchedulingDependencies(
dependent,
[dependent, ...allTasks],
[dependent, task, ...dependencyTasks],
markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
@@ -887,7 +894,6 @@ export class Scheduler {
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
const dependents = [...todoTasks, ...inProgressTasks];
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
for (const dependent of dependents) {
const mentionsDeletedTask = dependent.dependencies.includes(task.id);
@@ -897,9 +903,12 @@ export class Scheduler {
const markerAcceptedByTaskId = settings.mergeRequestContractShadowEnabled === true
? new Map(await Promise.all(dependent.dependencies.map(async (depId) => [depId, (await this.store.getCompletionHandoffAcceptedMarker(depId)) !== null] as const)))
: undefined;
const dependencyTasks = (await Promise.all(
dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)),
)).filter((candidate) => candidate !== null);
const unresolvedDeps = getUnmetSchedulingDependencies(
dependent,
[dependent, ...allTasks],
[dependent, ...dependencyTasks],
markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
@@ -997,12 +1006,15 @@ export class Scheduler {
// and start watching all missions with autopilotEnabled: true
if (this.options.missionAutopilot && this.options.missionStore) {
this.options.missionAutopilot.setScheduler(this);
const missions = this.options.missionStore.listMissions();
for (const mission of missions) {
if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") {
this.options.missionAutopilot.watchMission(mission.id);
const missionStore = this.options.missionStore;
const missionAutopilot = this.options.missionAutopilot;
void Promise.resolve(missionStore.listMissions()).then((missions) => {
for (const mission of missions) {
if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") {
missionAutopilot.watchMission(mission.id);
}
}
}
}).catch((error) => schedulerLog.error("Failed to initialize mission autopilot watches:", error));
this.options.missionAutopilot.start();
}
}
@@ -1460,11 +1472,11 @@ export class Scheduler {
for (const t of todo) {
if (t.sliceId && !blockedSliceIds.has(t.sliceId)) {
try {
const slice = this.options.missionStore.getSlice(t.sliceId);
const slice = await this.options.missionStore.getSlice(t.sliceId);
if (slice) {
const milestone = this.options.missionStore.getMilestone(slice.milestoneId);
const milestone = await this.options.missionStore.getMilestone(slice.milestoneId);
if (milestone) {
const mission = this.options.missionStore.getMission(milestone.missionId);
const mission = await this.options.missionStore.getMission(milestone.missionId);
if (mission && mission.status === "blocked") {
blockedSliceIds.add(t.sliceId);
}
@@ -2336,9 +2348,9 @@ export class Scheduler {
if (this.options.missionStore && task.sliceId) {
try {
const slice = this.options.missionStore.getSlice(task.sliceId);
const milestone = slice ? this.options.missionStore.getMilestone(slice.milestoneId) : undefined;
const mission = milestone ? this.options.missionStore.getMission(milestone.missionId) : undefined;
const slice = await this.options.missionStore.getSlice(task.sliceId);
const milestone = slice ? await this.options.missionStore.getMilestone(slice.milestoneId) : undefined;
const mission = milestone ? await this.options.missionStore.getMission(milestone.missionId) : undefined;
if (mission?.status === "blocked") {
await this.store.updateTask(task.id, { status: "queued" });
await this.logDispatchQueuedReason(task.id, "queued — mission is blocked");
@@ -2867,7 +2879,7 @@ export class Scheduler {
return;
}
const feature = this.resolveMissionFeatureForTask(missionStore, task);
const feature = await this.resolveMissionFeatureForTask(missionStore, task);
if (!feature) {
schedulerLog.log(`No linked feature found for task ${taskId} (sliceId=${task.sliceId ?? "none"}) — skipping mission status update`);
return;
@@ -2880,9 +2892,7 @@ export class Scheduler {
return;
}
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
? missionStore.listAssertionsForFeature(feature.id).length > 0
: false;
const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(feature.id)).length > 0;
const reconciliation = await reconcileMissionFeatureState(
this.store,
@@ -2904,7 +2914,7 @@ export class Scheduler {
const sliceIdBeforeUpdate = feature.sliceId;
if (reconciliation.kind === "update") {
missionStore.updateFeatureStatus(feature.id, reconciliation.status);
await missionStore.updateFeatureStatus(feature.id, reconciliation.status);
schedulerLog.log(
`Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`,
);
@@ -2918,8 +2928,8 @@ export class Scheduler {
}
}
private resolveMissionFeatureForTask(missionStore: MissionStore, task: Task): MissionFeature | undefined {
const linkedFeature = missionStore.getFeatureByTaskId(task.id);
private async resolveMissionFeatureForTask(missionStore: MissionStore | AsyncMissionStore, task: Task): Promise<MissionFeature | undefined> {
const linkedFeature = await missionStore.getFeatureByTaskId(task.id);
if (linkedFeature) {
return linkedFeature;
}
@@ -2929,8 +2939,8 @@ export class Scheduler {
}
const normalizedTaskTitle = this.normalizeMissionFeatureTitle(task.title);
const matchingFeature = missionStore
.listFeatures(task.sliceId)
const matchingFeature = (await missionStore
.listFeatures(task.sliceId))
.find((feature) =>
!feature.taskId
&& this.normalizeMissionFeatureTitle(feature.title) === normalizedTaskTitle
@@ -2967,7 +2977,7 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const feature = missionStore.getFeatureByTaskId(taskId);
const feature = await missionStore.getFeatureByTaskId(taskId);
if (!feature) return;
if (feature.sliceId !== sliceId) {
@@ -2996,7 +3006,7 @@ export class Scheduler {
}
// Check if the slice became complete after the feature update
const slice = missionStore.getSlice(sliceIdBeforeUpdate);
const slice = await missionStore.getSlice(sliceIdBeforeUpdate);
if (slice && slice.status === "complete") {
// If MissionAutopilot is available AND actively watching this mission,
// delegate progression to it. The autopilot handles: watching missions,
@@ -3007,7 +3017,7 @@ export class Scheduler {
// autoAdvance=true but no autopilot instance, or autopilot unwatched),
// fall back to onSliceComplete() which uses the compatibility rule.
const autopilot = this.options.missionAutopilot;
const milestone = missionStore.getMilestone(slice.milestoneId);
const milestone = await missionStore.getMilestone(slice.milestoneId);
const missionId = milestone?.missionId;
const isWatching = autopilot && missionId ? autopilot.isWatching(missionId) : false;
@@ -3031,13 +3041,13 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const milestone = missionStore.getMilestone(slice.milestoneId);
const milestone = await missionStore.getMilestone(slice.milestoneId);
if (!milestone) {
schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${slice.id}`);
return;
}
const mission = missionStore.getMission(milestone.missionId);
const mission = await missionStore.getMission(milestone.missionId);
// Use autopilotEnabled as canonical, fall back to autoAdvance for backward compat
const shouldAutoAdvance =
mission?.autopilotEnabled === true || mission?.autoAdvance === true;
@@ -3045,7 +3055,7 @@ export class Scheduler {
return;
}
const missionHierarchy = missionStore.getMissionWithHierarchy(mission.id);
const missionHierarchy = await missionStore.getMissionWithHierarchy(mission.id);
const hasActiveSlice = missionHierarchy?.milestones.some((candidateMilestone) =>
candidateMilestone.slices.some((candidateSlice) =>
candidateSlice.id !== slice.id && candidateSlice.status === "active"
@@ -3079,7 +3089,7 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const mission = missionStore.getMissionWithHierarchy(missionId);
const mission = await missionStore.getMissionWithHierarchy(missionId);
if (!mission || mission.status !== "active") {
schedulerLog.log(`Mission ${missionId}: not active, skipping slice activation`);
return null;
@@ -3135,7 +3145,7 @@ export class Scheduler {
let totalFixed = 0;
try {
const missions = missionStore.listMissions();
const missions = await missionStore.listMissions();
const activeMissions = missions.filter((m) => m.status === "active");
const activeMissionIds = new Set(activeMissions.map((mission) => mission.id));
const taskBySliceAndTitle = new Map<string, Task | null>();
@@ -3154,7 +3164,7 @@ export class Scheduler {
}
for (const mission of activeMissions) {
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
const hierarchy = await missionStore.getMissionWithHierarchy(mission.id);
if (!hierarchy) continue;
const activeSlices = hierarchy.milestones
@@ -3163,8 +3173,7 @@ export class Scheduler {
for (const slice of activeSlices) {
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures?.(slice.id)
?? { supersededCount: 0, featureIds: [] };
const supersededFixes = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
if (supersededFixes.supersededCount > 0) {
totalFixed += supersededFixes.supersededCount;
schedulerLog.warn(
@@ -3172,12 +3181,12 @@ export class Scheduler {
);
}
const features = supersededFixes.supersededCount > 0
? missionStore.listFeatures(slice.id)
? await missionStore.listFeatures(slice.id)
: slice.features;
const supersededFeatureIds = new Set(supersededFixes.featureIds);
if (supersededFixes.supersededCount > 0) {
const refreshedSlice = missionStore.getSlice?.(slice.id);
const refreshedSlice = await missionStore.getSlice(slice.id);
if (refreshedSlice?.status === "complete") {
/*
FNXC:Missions 2026-07-11-12:35:
@@ -3218,7 +3227,7 @@ export class Scheduler {
schedulerLog.warn(
`Repairing one-way mission link during reconciliation: task ${matchedTask.id} matched unlinked feature ${feature.id}`,
);
featureForReconciliation = missionStore.linkFeatureToTask(feature.id, matchedTask.id);
featureForReconciliation = await missionStore.linkFeatureToTask(feature.id, matchedTask.id);
task = matchedTask;
totalFixed++;
await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, feature.id, matchedTask.id);
@@ -3231,7 +3240,7 @@ export class Scheduler {
schedulerLog.warn(
`Blocking stranded generated fix feature ${feature.id}: no linked task and no title-matched task available`,
);
missionStore.updateFeature(feature.id, {
await missionStore.updateFeature(feature.id, {
status: "blocked",
loopState: "blocked",
taskId: undefined,
@@ -3246,7 +3255,7 @@ export class Scheduler {
try {
const featureToTriage = feature.status === "defined"
? feature
: missionStore.updateFeature(feature.id, {
: await missionStore.updateFeature(feature.id, {
status: "defined",
loopState: "idle",
taskId: undefined,
@@ -3282,9 +3291,7 @@ export class Scheduler {
if (!task) continue;
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
? missionStore.listAssertionsForFeature(featureForReconciliation.id).length > 0
: false;
const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(featureForReconciliation.id)).length > 0;
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation, {
hasLinkedAssertions,
});
@@ -3305,7 +3312,7 @@ export class Scheduler {
}
if (reconciliation.kind === "update") {
missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status);
await missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status);
totalFixed++;
}
}

View File

@@ -1000,13 +1000,11 @@ export class SelfHealingManager {
return handedOff;
}
private hasRecentWorktreeIncompleteDetected(taskId: string, graceMs: number): boolean {
private async hasRecentWorktreeIncompleteDetected(taskId: string, graceMs: number): Promise<boolean> {
if (!Number.isFinite(graceMs) || graceMs <= 0) return false;
const storeWithRunAudit = this.store as { getRunAuditEvents?: (filter: { taskId: string; mutationType: string; limit: number }) => Array<{ timestamp?: string | null }> };
if (typeof storeWithRunAudit.getRunAuditEvents !== "function") return false;
let events: Array<{ timestamp?: string | null }> = [];
try {
events = storeWithRunAudit.getRunAuditEvents({ taskId, mutationType: "worktree:incomplete-detected", limit: 20 }) ?? [];
events = await this.store.getRunAuditEventsAsync({ taskId, mutationType: "worktree:incomplete-detected", limit: 20 });
} catch {
return false;
}
@@ -1095,7 +1093,7 @@ export class SelfHealingManager {
const anchorMs = input.stalenessAnchor ? Date.parse(input.stalenessAnchor) : Number.NaN;
const stalenessMs = Number.isFinite(anchorMs) ? Math.max(0, Date.now() - anchorMs) : Number.POSITIVE_INFINITY;
const noRecentActivity = stalenessMs >= input.graceMs && !this.hasRecentWorktreeIncompleteDetected(task.id, input.graceMs);
const noRecentActivity = stalenessMs >= input.graceMs && !(await this.hasRecentWorktreeIncompleteDetected(task.id, input.graceMs));
const ok = sessionDead && worktreeUnusable && noRecentActivity;
return {
@@ -2511,18 +2509,9 @@ export class SelfHealingManager {
log.log("Maintenance batch 1 step \"prune-operational-logs\" skipped — operationalLogRetentionDays is not enabled");
return;
}
/*
* FNXC:SqliteFinalRemoval 2026-06-25-16:15:
* pruneOperationalLogs uses SQLite-specific DELETE on operational
* log tables. In backend mode, PostgreSQL autovacuum handles
* bloat; the operational-log pruning path is skipped until a PG
* equivalent is wired.
*/
if (this.store.isBackendMode()) {
log.log("Maintenance batch 1 step \"prune-operational-logs\" skipped — backend mode (PostgreSQL autovacuum)");
return;
}
const { deletedTotal, deletedByTable } = this.store.pruneOperationalLogs(days * 86_400_000);
// FNXC:PostgresRetention 2026-07-14-17:16: Autovacuum cannot replace
// retention; await project-scoped deletes on the PostgreSQL layer.
const { deletedTotal, deletedByTable } = await this.store.pruneOperationalLogsAsync(days * 86_400_000);
const detail = Object.entries(deletedByTable)
.filter(([, n]) => n > 0)
.map(([t, n]) => `${t}=${n}`)
@@ -5161,15 +5150,29 @@ export class SelfHealingManager {
async reconcileSoftDeletedColumnDrift(): Promise<{ reconciled: number }> {
try {
// FNXC:RuntimeSatelliteAsync 2026-06-24-22:00:
// In backend mode, the sync SQLite database is not available. The
// column-drift reconciliation uses direct SQL against the sync DB.
// Backend mode does not need this reconciliation (PostgreSQL enforces
// constraints at the DB level), so skip it.
if (this.store.isBackendMode()) return { reconciled: 0 };
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return { reconciled: 0 };
if (this.store.isBackendMode()) {
/*
FNXC:PostgresSoftDeleteRepair 2026-07-14-17:32:
PostgreSQL constraints do not prevent a soft-deleted task from drifting out of the archived column. Run the same per-row repair and durable audit contract as the legacy store instead of silently skipping the invariant.
*/
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("fn5566-soft-delete-column", "global"),
agentId: "self-healing",
phase: "reconcile-soft-delete-column-drift",
});
return this.store.reconcileSoftDeletedColumnDriftBackend(async (candidate) => {
await auditor.database({
type: "task:soft-delete-column-reconciled",
target: candidate.id,
metadata: { previousColumn: candidate.previousColumn },
});
log.log(`[self-heal] reconcile-soft-delete-column-drift: ${candidate.id} previous=${candidate.previousColumn} → archived`);
});
}
const db = this.store.getDatabase();
// FN-5147 invariant: only rows with deletedAt are eligible, so live
// in-review tasks (including autoMerge: false workflows) are never moved.
@@ -12025,7 +12028,7 @@ export class SelfHealingManager {
}
if (prunedBranches.length > 0) {
const cleared = this.store.clearStaleExecutionStartBranchReferences(prunedBranches);
const cleared = await this.store.clearStaleExecutionStartBranchReferences(prunedBranches);
if (cleared.length > 0) {
log.log(`Cleared stale baseBranch on ${cleared.length} task(s): ${cleared.join(", ")}`);
}

View File

@@ -23,7 +23,7 @@ export interface WorkflowAuthoritativeDriverStore {
getTask(taskId: string): Promise<TaskDetail>;
getTaskWorkflowSelection?(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
getTaskWorkflowSelectionAsync?(taskId: string): Promise<{ workflowId: string; stepIds: string[] } | undefined>;
getWorkflowParitySummary?(options?: { since?: string; limit?: number }): WorkflowParitySummary;
getWorkflowParitySummary?(options?: { since?: string; limit?: number }): WorkflowParitySummary | Promise<WorkflowParitySummary>;
}
export interface WorkflowAuthoritativeDriverDeps {
@@ -128,7 +128,8 @@ export class WorkflowAuthoritativeDriver {
let paritySummary: WorkflowParitySummary | undefined;
try {
settings = await this.deps.store.getSettings();
paritySummary = this.deps.store.getWorkflowParitySummary?.();
/* FNXC:WorkflowParityPostgres 2026-07-14-18:12: Readiness must await the PostgreSQL audit aggregation before deciding whether authoritative workflow execution is safe. */
paritySummary = await this.deps.store.getWorkflowParitySummary?.();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
executorLog.warn(`[workflow-authoritative] ${task.id}: readiness probe failed — falling back to legacy (${message})`);