import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { normalizeTaskPriority } from "./task-priority.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { MissionStore } from "./mission-store.js"; import { PluginStore } from "./plugin-store.js"; import { InsightStore } from "./insight-store.js"; import { ResearchStore } from "./research-store.js"; import { ExperimentSessionStore } from "./experiment-session-store.js"; import { TodoStore } from "./todo-store.js"; import { EvalStore } from "./eval-store.js"; import { BackwardCompat, ProjectRequiredError } from "./migration.js"; import { CentralCore } from "./central-core.js"; import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js"; import { getInReviewStallReason } from "./in-review-stall.js"; import { getTaskAgeStalenessSignal, type TaskAgeStalenessThresholds } from "./task-age-staleness.js"; import { ensureMemoryFileWithBackend } from "./project-memory.js"; import { runCommandAsync } from "./run-command.js"; import { createLogger } from "./logger.js"; import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle } from "./ai-summarize.js"; import { assertProjectRootDir } from "./project-root-guard.js"; import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js"; import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js"; import { detectStalledReview } from "./stalled-review-detector.js"; import { detectTaskIdIntegrityAnomalies, type TaskIdIntegrityReport, } from "./task-id-integrity.js"; import { buildBootstrapPrompt, replicationCollisionError, taskMatchesReplicatedCreate, } from "./mesh-task-replication.js"; import type { MeshReplicatedTaskApplyResult, MeshReplicatedTaskCreatePayload } from "./types.js"; /** Database row shape for the tasks table (all columns). */ interface TaskRow { id: string; lineageId: string | null; title: string | null; description: string; priority: string | null; column: string; status: string | null; size: string | null; reviewLevel: number | null; currentStep: number; worktree: string | null; blockedBy: string | null; paused: number | null; baseBranch: string | null; executionStartBranch: string | null; branch: string | null; baseCommitSha: string | null; modelPresetId: string | null; modelProvider: string | null; modelId: string | null; validatorModelProvider: string | null; validatorModelId: string | null; planningModelProvider: string | null; planningModelId: string | null; mergeRetries: number | null; workflowStepRetries: number | null; stuckKillCount: number | null; postReviewFixCount: number | null; recoveryRetryCount: number | null; taskDoneRetryCount: number | null; verificationFailureCount: number | null; mergeConflictBounceCount: number | null; mergeAuditBounceCount: number | null; nextRecoveryAt: string | null; error: string | null; summary: string | null; thinkingLevel: string | null; executionMode: string | null; tokenUsageInputTokens: number | null; tokenUsageOutputTokens: number | null; tokenUsageCachedTokens: number | null; tokenUsageCacheWriteTokens: number | null; tokenUsageTotalTokens: number | null; tokenUsageFirstUsedAt: string | null; tokenUsageLastUsedAt: string | null; tokenBudgetSoftAlertedAt: string | null; tokenBudgetHardAlertedAt: string | null; tokenBudgetOverride: string | null; createdAt: string; updatedAt: string; columnMovedAt: string | null; executionStartedAt: string | null; executionCompletedAt: string | null; dependencies: string | null; steps: string | null; log: string | null; attachments: string | null; steeringComments: string | null; comments: string | null; review: string | null; reviewState: string | null; workflowStepResults: string | null; prInfo: string | null; issueInfo: string | null; githubTracking: string | null; sourceIssueProvider: string | null; sourceIssueRepository: string | null; sourceIssueExternalIssueId: string | null; sourceIssueNumber: number | null; sourceIssueUrl: string | null; mergeDetails: string | null; breakIntoSubtasks: number | null; enabledWorkflowSteps: string | null; modifiedFiles: string | null; missionId: string | null; sliceId: string | null; scopeOverride: number | null; scopeOverrideReason: string | null; assignedAgentId: string | null; pausedByAgentId: string | null; assigneeUserId: string | null; nodeId: string | null; effectiveNodeId: string | null; effectiveNodeSource: string | null; sourceType: string | null; sourceAgentId: string | null; sourceRunId: string | null; sourceSessionId: string | null; sourceMessageId: string | null; sourceParentTaskId: string | null; sourceMetadata: string | null; checkedOutBy: string | null; checkedOutAt: string | null; checkoutNodeId: string | null; checkoutRunId: string | null; checkoutLeaseRenewedAt: string | null; checkoutLeaseEpoch: number | null; } /** Database row shape for the task_documents table. */ const TASK_BRANCH_CONTEXT_METADATA_KEY = "fusionBranchContext"; function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record | undefined): import("./types.js").TaskBranchContext | undefined { const raw = sourceMetadata?.[TASK_BRANCH_CONTEXT_METADATA_KEY]; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const candidate = raw as Record; if (typeof candidate.groupId !== "string" || !candidate.groupId.trim()) return undefined; if (candidate.source !== "planning" && candidate.source !== "mission") return undefined; if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined; const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0 ? candidate.inheritedBaseBranch.trim() : undefined; return { groupId: candidate.groupId, source: candidate.source, assignmentMode: candidate.assignmentMode, inheritedBaseBranch, }; } function withTaskBranchContextInSourceMetadata( sourceMetadata: Record | undefined, branchContext: import("./types.js").TaskBranchContext | undefined, ): Record | undefined { if (!branchContext) return sourceMetadata; return { ...(sourceMetadata ?? {}), [TASK_BRANCH_CONTEXT_METADATA_KEY]: { groupId: branchContext.groupId, source: branchContext.source, assignmentMode: branchContext.assignmentMode, ...(branchContext.inheritedBaseBranch ? { inheritedBaseBranch: branchContext.inheritedBaseBranch } : {}), }, }; } interface TaskCommitAssociationRow { id: string; taskLineageId: string; taskIdSnapshot: string; commitSha: string; commitSubject: string; authoredAt: string; matchedBy: TaskCommitAssociationMatchSource; confidence: TaskCommitAssociationConfidence; note: string | null; createdAt: string; updatedAt: string; } interface TaskDocumentRow { id: string; taskId: string; key: string; content: string; revision: number; author: string; metadata: string | null; createdAt: string; updatedAt: string; } /** Database row shape for the task_document_revisions table. */ interface TaskDocumentRevisionRow { id: number; taskId: string; key: string; content: string; revision: number; author: string; metadata: string | null; createdAt: string; } /** Database row shape for the runAuditEvents table. */ interface RunAuditEventRow { id: string; timestamp: string; taskId: string | null; agentId: string; runId: string; domain: string; mutationType: string; target: string; metadata: string | null; } /** Database row shape for the config table. */ interface ConfigRow { nextId: number; settings: string | null; nextWorkflowStepId: number | null; } /** Database row shape for the activityLog table. */ interface ActivityLogRow { id: string; timestamp: string; type: string; taskId: string | null; taskTitle: string | null; details: string; metadata: string | null; } function normalizeTaskReviewState(reviewState: Task["reviewState"] | undefined): Task["reviewState"] | undefined { if (!reviewState) { return undefined; } const itemsById = new Map(reviewState.items.map((item) => [item.id, item])); const sourceMode = reviewState.source; const normalizedAddressing = reviewState.addressing.map((record) => { const item = itemsById.get(record.itemId); const source = item?.source === "reviewer-agent" ? "reviewer-agent" : "pr-review"; const summary = item?.summary?.trim() || item?.body?.trim().slice(0, 160) || `Review item ${record.itemId}`; const body = item?.body ?? summary; return { ...record, snapshot: record.snapshot ?? { itemId: record.itemId, sourceMode, source, summary, body, authorLogin: item?.author?.login, filePath: item?.path, threadId: item?.threadId, url: item?.htmlUrl, }, }; }); return { ...reviewState, addressing: normalizedAddressing, }; } const TASK_ACTIVITY_LOG_ENTRY_LIMIT = 1_000; const TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4_000; const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25; const ARCHIVE_AGENT_LOG_SNIPPET_LIMIT = 160; const AGENT_LOG_TOOL_DETAIL_LIMIT = 4_096; const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE = "\n\n[tool output truncated to keep dashboard log views responsive]"; const AGENT_LOG_TOOL_TYPES = new Set(["tool", "tool_result", "tool_error"]); const storeLog = createLogger("task-store"); const coreLog = createLogger("core"); /** * Reject branch names that would be unsafe to interpolate into a shell command. * The allowed set is a conservative subset of git's refname rules: alphanumerics, * `_`, `.`, `/`, `+`, and `-`, with the same leading/trailing/segment restrictions * git enforces. Any branch that fails this check is rejected before reaching the * shell, so no branch-name value can inject shell metacharacters. */ function assertSafeGitBranchName(name: string): void { if ( !name || name.length > 255 || name.startsWith("-") || name.startsWith(".") || name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.endsWith(".lock") || name.includes("..") || name.includes("@{") || !/^[A-Za-z0-9._/+-]+$/.test(name) ) { throw new Error(`Unsafe git branch name: ${JSON.stringify(name)}`); } } /** * Reject filesystem paths that would be unsafe to interpolate into a shell * command. Worktree paths are generated by fusion itself and are expected to * be absolute, but `task.worktree` is writable via the authenticated API, so * validate at the shell boundary as defense-in-depth. */ function assertSafeAbsolutePath(path: string): void { const isAbsolute = path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path); if ( !path || path.length > 4096 || !isAbsolute || path.startsWith("-") || // Reject shell metacharacters, quotes, control chars, and NULs. /["'`$\n\r\t;&|<>()*?[\]{}\\\0]/.test( path.replace(/^[A-Za-z]:/, ""), // ignore the drive-letter colon on Windows ) ) { throw new Error(`Unsafe path: ${JSON.stringify(path)}`); } } function truncateTaskLogOutcome(outcome: string | undefined): string | undefined { if (!outcome || outcome.length <= TASK_ACTIVITY_LOG_OUTCOME_LIMIT) { return outcome; } return `${outcome.slice(0, TASK_ACTIVITY_LOG_OUTCOME_LIMIT)}\n... outcome truncated to ${TASK_ACTIVITY_LOG_OUTCOME_LIMIT} characters ...`; } function truncateAgentLogDetail( detail: string | null | undefined, type: AgentLogEntry["type"], ): string | undefined { if (detail == null) return undefined; if (!AGENT_LOG_TOOL_TYPES.has(type)) return detail; if (detail.length <= AGENT_LOG_TOOL_DETAIL_LIMIT) return detail; return `${detail.slice(0, AGENT_LOG_TOOL_DETAIL_LIMIT)}${AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE}`; } function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] { const recentEntries = entries.slice(-TASK_ACTIVITY_LOG_ENTRY_LIMIT); return recentEntries.map((entry) => ({ ...entry, outcome: truncateTaskLogOutcome(entry.outcome), })); } /** * Detect whether a PROMPT.md body is the auto-generated bootstrap stub * (`# heading\n\n\n`) that `createTask` writes for triage tasks, * versus a real specification produced by triage or planning. * * Detection is wrapper-shape-exact: the on-disk content is compared against * the exact bytes `createTask` would have written for the *pre-update* * title/description. Earlier heuristic detectors (size caps, `##` header * presence, `**Created:**` / `**Size:**` markers) misfired on imported issue * bodies that contain `## Repro`, `**Created:** ...`, etc. — those are real * stubs but look like real specs to a content-inspecting check. By matching * against the wrapper produced from the previous title/description, we are * robust to anything the description itself contains. */ function isBootstrapPromptStub( content: string, taskId: string, preUpdateTitle: string | undefined, preUpdateDescription: string, ): boolean { return content === buildBootstrapPrompt(taskId, preUpdateTitle, preUpdateDescription); } /** * Replace just the leading `# ...` heading line of a PROMPT.md body, leaving * every other section untouched. Used when a metadata edit (title or * description change) needs to keep the displayed heading in sync without * disturbing the rest of a real specification. * * If the file does not start with a `#` heading, it is returned verbatim — * the caller has no clean place to splice the heading and the spec's content * is more important to preserve than the displayed title (task.json is the * canonical source for title/description anyway). */ function rewriteHeadingLine(content: string, newHeading: string): string { const match = content.match(/^#[^\n]*\n?/); if (!match) { return content; } const trailingNewline = match[0].endsWith("\n") ? "\n" : ""; return `# ${newHeading}${trailingNewline}${content.slice(match[0].length)}`; } /** * Replace the body of the `## Mission` section with `newDescription`, leaving * every other section untouched. Used to propagate `task.description` edits * into a real spec without disturbing custom sections (Review Level, Frontend * UX Criteria, File Scope, Acceptance Criteria, etc.) that a section-whitelist * regen would silently drop. * * Returns the original content unchanged if there is no `## Mission` section. */ function rewriteMissionSection(content: string, newDescription: string): string { const missionMatch = content.match(/^##\s+Mission\s*$/m); if (!missionMatch || missionMatch.index === undefined) { return content; } const headerEnd = missionMatch.index + missionMatch[0].length; const rest = content.slice(headerEnd); // Find the next `## ` heading (start of next section). The match position is // relative to `rest`, so we re-anchor to the absolute offset. const nextHeading = rest.search(/\n##\s/); const sectionEndAbsolute = nextHeading === -1 ? content.length : headerEnd + nextHeading; const before = content.slice(0, headerEnd); const after = content.slice(sectionEndAbsolute); // Reconstruct: header line + blank line + new description + blank line + // trailing content (which begins with the newline before the next heading). return `${before}\n\n${newDescription}\n${after}`; } /** * Canonicalizes a settings object by stripping legacy fields that are no longer valid * and rewriting legacy path values left over from the kb → fn rename. */ function canonicalizeSettings(settings: Settings): Settings { // Strip legacy globalMaxConcurrent from project settings - this field was // deprecated in favor of the global-level maxConcurrent in concurrency settings. const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number }; const base = globalMaxConcurrent !== undefined ? (rest as Settings) : settings; // Rewrite legacy .kb/backups → .fusion/backups for projects upgraded from the // old brand so persisted settings keep working. Custom .kb/* paths are left alone. if (base.autoBackupDir === ".kb/backups") { return { ...base, autoBackupDir: ".fusion/backups" }; } return base; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function deepMergeWithNullDelete( existingValue: unknown, patchValue: Record, ): Record | undefined { const merged: Record = isPlainObject(existingValue) ? { ...existingValue } : {}; for (const [key, value] of Object.entries(patchValue)) { if (value === null) { delete merged[key]; continue; } if (isPlainObject(value)) { const nested = deepMergeWithNullDelete(merged[key], value); if (nested === undefined) { delete merged[key]; } else { merged[key] = nested; } continue; } merged[key] = value; } return Object.keys(merged).length > 0 ? merged : undefined; } export interface TaskStoreEvents { "task:created": [task: Task]; "task:moved": [data: { task: Task; from: Column; to: Column }]; "task:updated": [task: Task]; "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; "task:merged": [result: MergeResult]; "settings:updated": [data: { settings: Settings; previous: Settings }]; "agent:log": [entry: AgentLogEntry]; "merger:autostashOrphans": [data: { rootDir: string; records: AutostashOrphanRecord[]; }]; } /** * Thrown by {@link TaskStore.deleteTask} when the target task is still * referenced by at least one other live task's `dependencies` array. * * Callers that intend to split a task into children (e.g. triage, the * dashboard subtask-breakdown endpoint) must rewrite or drop those * references *before* deleting the parent — otherwise the dependents * would be permanently blocked by a nonexistent id. */ export class TaskHasDependentsError extends Error { readonly taskId: string; readonly dependentIds: string[]; constructor(taskId: string, dependentIds: string[]) { super( `Cannot delete task ${taskId}: still referenced as a dependency by ${dependentIds.join(", ")}. ` + `Rewrite or remove these dependencies before deleting.`, ); this.name = "TaskHasDependentsError"; this.taskId = taskId; this.dependentIds = dependentIds; } } export class InvalidFileScopeError extends Error { readonly taskId: string; readonly invalidEntries: string[]; constructor(taskId: string, invalidEntries: string[]) { super( `Invalid File Scope entries in PROMPT.md for ${taskId}: ${invalidEntries.join(", ")}. ` + "File Scope must contain repo-relative file paths or globs (e.g. `packages/core/src/store.ts`, `packages/engine/src/**/*.ts`), not git refs or identifiers.", ); this.name = "InvalidFileScopeError"; this.taskId = taskId; this.invalidEntries = invalidEntries; } } const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ "makefile", "dockerfile", "justfile", "license", "readme", "changelog", "agents.md", ]); export function isValidFileScopeEntry(token: string): boolean { const trimmed = token.trim(); if (!trimmed) return false; const lower = trimmed.toLowerCase(); if ( lower.startsWith("origin/") || lower.startsWith("upstream/") || lower.startsWith("refs/") || /^https?:\/\//i.test(trimmed) || /^git@/i.test(trimmed) || /^ssh:\/\//i.test(trimmed) || /^[a-z]+\/fn-\d+$/i.test(trimmed) || /^[a-f0-9]{7,}$/i.test(trimmed) || trimmed.includes("..") || trimmed.startsWith("/") ) { return false; } const segments = trimmed.split("/"); const lastSegment = segments[segments.length - 1]; const hasSlash = trimmed.includes("/"); const hasDotInLastSegment = lastSegment.includes("."); if (KNOWN_FILE_SCOPE_ROOT_FILES.has(lastSegment.toLowerCase())) { return true; } if (trimmed.includes("**") || trimmed.endsWith("/*") || (lastSegment.includes("*") && hasDotInLastSegment)) { return true; } if (hasSlash && hasDotInLastSegment) { return true; } return false; } function extractFileScopeTokens(content: string): string[] { const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m); if (!headingMatch) return []; const startIdx = headingMatch.index! + headingMatch[0].length; const rest = content.slice(startIdx); const nextHeading = rest.search(/\n##?\s/); const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); const tokens: string[] = []; const backtickRegex = /`([^`]+)`/g; let match; while ((match = backtickRegex.exec(section)) !== null) { tokens.push(match[1]); } return tokens; } function validateFileScopeInPromptContent(prompt: string): { valid: string[]; invalid: string[] } { const tokens = extractFileScopeTokens(prompt); const valid: string[] = []; const invalid: string[] = []; for (const token of tokens) { if (isValidFileScopeEntry(token)) { valid.push(token); } else { invalid.push(token); } } return { valid, invalid }; } export class TaskStore extends EventEmitter { static async getOrCreateForProject( projectId?: string, centralCore?: CentralCore, globalSettingsDir?: string, ): Promise { const central = centralCore ?? new CentralCore(); let initializedHere = false; if (!centralCore) { await central.init(); initializedHere = true; } try { const compat = new BackwardCompat(central); const context = await compat.resolveProjectContext(process.cwd(), projectId); const resolvedGlobalSettingsDir = globalSettingsDir ?? (process.env.VITEST === "true" ? join(context.workingDirectory, ".fusion-global-settings") : undefined); const store = new TaskStore(context.workingDirectory, resolvedGlobalSettingsDir); await store.init(); return store; } catch (error) { if (error instanceof ProjectRequiredError) { if (projectId) { throw new Error(`Project "${projectId}" not found`); } throw new Error(error.message); } throw error; } finally { if (initializedHere) { await central.close(); } } } /** * Hybrid storage note: task metadata lives in SQLite, while blob files remain on disk. * Any write to `.fusion/tasks/{id}` must recreate the directory on demand, and any read from * optional blob files must tolerate missing files/directories because cleanup, migration, * or manual filesystem changes can remove them independently of the database row. */ private fusionDir: string; private tasksDir: string; private configPath: string; /** SQLite database for structured data storage */ private _db: Database | null = null; private activityListenersWired = false; /** Separate SQLite database for compact archived task snapshots. */ private _archiveDb: ArchiveDatabase | null = null; /** File-system watcher instance */ private watcher: FSWatcher | null = null; /** In-memory cache of tasks for diffing watcher events */ private taskCache: Map = new Map(); /** Paths recently written by in-process mutations (suppresses duplicate events) */ private recentlyWritten: Set = new Set(); /** Pending debounce timers keyed by task ID */ private debounceTimers: Map> = new Map(); /** Debounce interval in ms */ private debounceMs = 150; /** Per-task promise chain for serializing writes */ private taskLocks: Map> = new Map(); /** * Cross-task lock for worktree path allocation. Serializes the * read-tasks → pick-name → write-task sequence so two concurrent * `moveTask` calls (or a moveTask vs. a scheduler dispatch) cannot * pick the same name from a stale snapshot. */ private worktreeAllocationLock: Promise = Promise.resolve(); /** Promise chain for serializing config.json read-modify-write cycles */ private configLock: Promise = Promise.resolve(); /** Startup/open guard for distributed_task_id_state reconciliation. */ private taskIdStateReconciled = false; /** Cached startup/refresh integrity report for allocator-related task ID anomalies. */ private taskIdIntegrityReport: TaskIdIntegrityReport = { status: "ok", checkedAt: new Date().toISOString(), anomalies: [], }; /** Prevent duplicate anomaly logs when the report content has not changed. */ private lastTaskIdIntegrityLogSignature: string | null = null; /** Cached workflow steps — invalidated on create/update/delete */ private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null; /** Plugin-contributed workflow step templates injected by engine runtime. */ private _pluginWorkflowStepTemplates: Array<{ pluginId: string; template: WorkflowStepTemplate }> = []; /** Global settings store (`~/.fusion/settings.json`) */ private globalSettingsStore: GlobalSettingsStore; /** Polling interval for change detection */ private pollInterval: ReturnType | null = null; /** Guard flag to prevent overlapping poll cycles */ private pollingInProgress = false; /** Last known modification timestamp for change detection */ private lastKnownModified: number = 0; /** ISO timestamp of last poll — used to filter changed tasks */ private lastPollTime: string | null = null; /** Short-lived startup memo for repeated slim listTasks reads before steady-state watch/polling. */ private startupSlimListMemo = new Map }>(); private static readonly STARTUP_SLIM_LIST_MEMO_TTL_MS = 2_500; /** Whether the store is actively watching for changes (watcher or polling). */ private get isWatching(): boolean { return this.watcher !== null || this.pollInterval !== null; } /** Cached MissionStore instance */ private missionStore: MissionStore | null = null; /** Cached PluginStore instance */ private pluginStore: PluginStore | null = null; /** Cached InsightStore instance */ private insightStore: InsightStore | null = null; /** Cached ResearchStore instance */ private researchStore: ResearchStore | null = null; /** Cached ExperimentSessionStore instance */ private experimentSessionStore: ExperimentSessionStore | null = null; /** Cached TodoStore instance */ private todoStore: TodoStore | null = null; /** Cached EvalStore instance */ private evalStore: EvalStore | null = null; /** Cached distributed task-id allocator instance. */ private distributedTaskIdAllocator: DistributedTaskIdAllocator | null = null; /** Buffer for batching agent log writes to reduce WAL pressure. */ private agentLogBuffer: Array<{ taskId: string; timestamp: string; text: string; type: string; detail: string | null; agent: string | null; }> = []; /** Timer for flushing the agent log buffer. */ private agentLogFlushTimer: ReturnType | null = null; /** Maximum buffer size before forced flush. */ private static readonly AGENT_LOG_BUFFER_SIZE = 50; /** Flush interval in milliseconds. */ private static readonly AGENT_LOG_FLUSH_MS = 2000; /** Absolute backlog cap — oldest entries are dropped when flushes keep failing. */ private static readonly MAX_AGENT_LOG_BACKLOG = 5_000; // Test-only: when true, both fusion.db and archive.db open as `:memory:` // SQLite connections instead of disk-backed files. Production code never // sets this; it's gated through an opt-in TaskStoreOptions field below. // Tests that need cross-instance persistence (open store A, close, // open store B on the same dir, expect data) must leave this false. private readonly inMemoryDb: boolean; private readonly globalSettingsDir?: string; constructor( private rootDir: string, globalSettingsDir?: string, options?: { inMemoryDb?: boolean }, ) { super(); this.setMaxListeners(100); assertProjectRootDir(rootDir, "TaskStore"); this.fusionDir = join(rootDir, ".fusion"); this.tasksDir = join(this.fusionDir, "tasks"); this.configPath = join(this.fusionDir, "config.json"); this.inMemoryDb = options?.inMemoryDb === true; const resolvedGlobalSettingsDir = globalSettingsDir ?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined); this.globalSettingsDir = resolvedGlobalSettingsDir; this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); } /** * Get the SQLite database, initializing it on first access. * Also performs auto-migration from legacy file-based storage if needed. */ private get db(): Database { if (!this._db) { const db = new Database(this.fusionDir, { inMemory: this.inMemoryDb }); try { db.init(); } catch (error) { db.close(); throw error; } this._db = db; this.reconcileDistributedTaskIdStateOnOpen(); // Auto-migrate legacy data if needed if (detectLegacyData(this.fusionDir)) { // Note: migrateFromLegacy is async but we need sync access. // The init() method handles async migration. This getter // just ensures the DB is available for synchronous operations. } } return this._db; } private get archiveDb(): ArchiveDatabase { if (!this._archiveDb) { const db = new ArchiveDatabase(this.fusionDir, { inMemory: this.inMemoryDb }); try { db.init(); } catch (error) { db.close(); throw error; } this._archiveDb = db; this.migrateLegacyArchiveEntriesToArchiveDb(); } return this._archiveDb; } private buildTaskIdIntegrityFallbackReport(): TaskIdIntegrityReport { return { status: "ok", checkedAt: new Date().toISOString(), anomalies: [], }; } private detectAndCacheTaskIdIntegrityReport(): TaskIdIntegrityReport { const report = detectTaskIdIntegrityAnomalies(this.db); this.taskIdIntegrityReport = report; const signature = report.status === "anomaly" ? JSON.stringify(report.anomalies) : null; if (report.status === "anomaly" && signature !== this.lastTaskIdIntegrityLogSignature) { coreLog.error("[task-id-integrity] anomaly detected", { anomalies: report.anomalies }); } this.lastTaskIdIntegrityLogSignature = signature; return report; } private mergeTaskIdIntegrityReports(...reports: TaskIdIntegrityReport[]): TaskIdIntegrityReport { const checkedAt = reports[reports.length - 1]?.checkedAt ?? new Date().toISOString(); const seen = new Set(); const anomalies = reports.flatMap((report) => report.anomalies).filter((anomaly) => { const key = JSON.stringify(anomaly); if (seen.has(key)) { return false; } seen.add(key); return true; }); return { status: anomalies.length > 0 ? "anomaly" : "ok", checkedAt, anomalies, }; } refreshTaskIdIntegrityReport(): TaskIdIntegrityReport { try { return this.detectAndCacheTaskIdIntegrityReport(); } catch (error) { const fallback = this.buildTaskIdIntegrityFallbackReport(); this.taskIdIntegrityReport = fallback; this.lastTaskIdIntegrityLogSignature = null; coreLog.warn("[task-id-integrity] detector failed; degrading to healthy report", { error: error instanceof Error ? error.message : String(error), }); return fallback; } } getTaskIdIntegrityReport(): TaskIdIntegrityReport { return this.taskIdIntegrityReport; } private reconcileDistributedTaskIdStateOnOpen(): void { if (this.taskIdStateReconciled) { return; } const previousReport = this.taskIdIntegrityReport; const preReconcileReport = this.refreshTaskIdIntegrityReport(); reconcileTaskIdState(this.db); const postReconcileReport = this.refreshTaskIdIntegrityReport(); this.taskIdIntegrityReport = this.mergeTaskIdIntegrityReports( previousReport, preReconcileReport, postReconcileReport, ); this.taskIdStateReconciled = true; } async init(): Promise { await mkdir(this.tasksDir, { recursive: true }); // Initialize SQLite database if (!this._db) { const db = new Database(this.fusionDir, { inMemory: this.inMemoryDb }); try { db.init(); } catch (error) { db.close(); throw error; } this._db = db; } this.reconcileDistributedTaskIdStateOnOpen(); // Auto-migrate from legacy file-based storage if (detectLegacyData(this.fusionDir)) { await migrateFromLegacy(this.fusionDir, this._db); } await this.migrateActiveArchivedTasksToArchiveDb(); await this.importLegacyAgentLogsOnce(); this.taskIdStateReconciled = false; this.reconcileDistributedTaskIdStateOnOpen(); // Write config.json for backward compatibility if it doesn't exist if (!existsSync(this.configPath)) { const config = await this.readConfig(); try { await writeFile(this.configPath, this.serializeConfigForDisk(config)); } catch (err) { storeLog.warn("Backward-compat config.json sync failed during init", { phase: "init:config-sync", configPath: this.configPath, error: err instanceof Error ? err.message : String(err), }); } } this.setupActivityLogListeners(); // Bootstrap project memory file if memory is enabled try { const config = await this.readConfig(); const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...config.settings }; if (mergedSettings.memoryEnabled !== false) { // Use backend-aware bootstrap to honor memoryBackendType setting await ensureMemoryFileWithBackend(this.rootDir, mergedSettings); } } catch (err) { // Non-fatal — memory bootstrap failure should not block startup storeLog.warn("Project-memory bootstrap failed during init", { phase: "init:memory-bootstrap", rootDir: this.rootDir, error: err instanceof Error ? err.message : String(err), }); } } // ── Row <-> Task Conversion ──────────────────────────────────────── /** * Convert a database row to a Task object, parsing JSON columns. */ private rowToTask(row: TaskRow): Task { return { id: row.id, lineageId: row.lineageId || generateTaskLineageId(), title: row.title || undefined, description: row.description, priority: normalizeTaskPriority(row.priority), column: row.column as Column, status: row.status || undefined, size: (row.size || undefined) as Task["size"], reviewLevel: row.reviewLevel ?? undefined, currentStep: row.currentStep || 0, worktree: row.worktree || undefined, blockedBy: row.blockedBy || undefined, paused: row.paused ? true : undefined, baseBranch: row.baseBranch || undefined, executionStartBranch: row.executionStartBranch || undefined, branch: row.branch || undefined, baseCommitSha: row.baseCommitSha || undefined, scopeOverride: row.scopeOverride ? true : undefined, scopeOverrideReason: row.scopeOverrideReason || undefined, modelPresetId: row.modelPresetId || undefined, modelProvider: row.modelProvider || undefined, modelId: row.modelId || undefined, validatorModelProvider: row.validatorModelProvider || undefined, validatorModelId: row.validatorModelId || undefined, planningModelProvider: row.planningModelProvider || undefined, planningModelId: row.planningModelId || undefined, mergeRetries: row.mergeRetries ?? undefined, workflowStepRetries: row.workflowStepRetries ?? undefined, stuckKillCount: row.stuckKillCount ?? undefined, postReviewFixCount: row.postReviewFixCount ?? undefined, recoveryRetryCount: row.recoveryRetryCount ?? undefined, taskDoneRetryCount: row.taskDoneRetryCount ?? undefined, verificationFailureCount: row.verificationFailureCount ?? undefined, mergeConflictBounceCount: row.mergeConflictBounceCount ?? undefined, mergeAuditBounceCount: row.mergeAuditBounceCount ?? undefined, nextRecoveryAt: row.nextRecoveryAt || undefined, error: row.error || undefined, summary: row.summary || undefined, thinkingLevel: (row.thinkingLevel || undefined) as Task["thinkingLevel"], executionMode: (row.executionMode || undefined) as Task["executionMode"], createdAt: row.createdAt, updatedAt: row.updatedAt, columnMovedAt: row.columnMovedAt || undefined, executionStartedAt: row.executionStartedAt || undefined, executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], steps: fromJson(row.steps) || [], log: fromJson(row.log) || [], tokenBudgetSoftAlertedAt: row.tokenBudgetSoftAlertedAt || undefined, tokenBudgetHardAlertedAt: row.tokenBudgetHardAlertedAt || undefined, tokenBudgetOverride: fromJson(row.tokenBudgetOverride) ?? undefined, tokenUsage: (() => { if ( row.tokenUsageInputTokens === null || row.tokenUsageOutputTokens === null || row.tokenUsageCachedTokens === null || row.tokenUsageTotalTokens === null || row.tokenUsageFirstUsedAt === null || row.tokenUsageLastUsedAt === null ) { return undefined; } return { inputTokens: row.tokenUsageInputTokens, outputTokens: row.tokenUsageOutputTokens, cachedTokens: row.tokenUsageCachedTokens, cacheWriteTokens: row.tokenUsageCacheWriteTokens ?? 0, totalTokens: row.tokenUsageTotalTokens, firstUsedAt: row.tokenUsageFirstUsedAt, lastUsedAt: row.tokenUsageLastUsedAt, }; })(), attachments: (() => { const a = fromJson(row.attachments); return a && a.length > 0 ? a : undefined; })(), steeringComments: (() => { const sc = fromJson(row.steeringComments); return sc && sc.length > 0 ? sc : undefined; })(), comments: (() => { // Comments column already contains steering comments (addSteeringComment calls addComment). // Do NOT merge steeringComments here — that caused duplication on every read-write cycle. const c = fromJson(row.comments) || []; // Deduplicate by id to recover from prior corruption const seen = new Set(); const deduped = c.filter(entry => { if (seen.has(entry.id)) return false; seen.add(entry.id); return true; }); return deduped.length > 0 ? deduped : undefined; })(), review: fromJson(row.review) ?? undefined, reviewState: normalizeTaskReviewState(fromJson(row.reviewState) ?? undefined), workflowStepResults: (() => { const w = fromJson(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(), prInfo: fromJson(row.prInfo), issueInfo: fromJson(row.issueInfo), githubTracking: fromJson(row.githubTracking) ?? undefined, sourceIssue: (() => { if ( row.sourceIssueProvider === null || row.sourceIssueRepository === null || row.sourceIssueExternalIssueId === null || row.sourceIssueNumber === null ) { return undefined; } return { provider: row.sourceIssueProvider, repository: row.sourceIssueRepository, externalIssueId: row.sourceIssueExternalIssueId, issueNumber: row.sourceIssueNumber, url: row.sourceIssueUrl ?? undefined, }; })(), mergeDetails: fromJson(row.mergeDetails), breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined, enabledWorkflowSteps: (() => { const e = fromJson(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(), modifiedFiles: (() => { const m = fromJson(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(), missionId: row.missionId || undefined, sliceId: row.sliceId || undefined, assignedAgentId: row.assignedAgentId || undefined, pausedByAgentId: row.pausedByAgentId || undefined, assigneeUserId: row.assigneeUserId || undefined, nodeId: row.nodeId || undefined, effectiveNodeId: row.effectiveNodeId || undefined, effectiveNodeSource: (row.effectiveNodeSource as Task["effectiveNodeSource"]) || undefined, sourceType: (row.sourceType as SourceType) || undefined, sourceAgentId: row.sourceAgentId || undefined, sourceRunId: row.sourceRunId || undefined, sourceSessionId: row.sourceSessionId || undefined, sourceMessageId: row.sourceMessageId || undefined, sourceParentTaskId: row.sourceParentTaskId || undefined, sourceMetadata: (() => { const parsed = fromJson>(row.sourceMetadata) ?? undefined; return withTaskBranchContextInSourceMetadata(parsed, parseTaskBranchContextFromSourceMetadata(parsed)); })(), branchContext: (() => { const parsed = fromJson>(row.sourceMetadata) ?? undefined; return parseTaskBranchContextFromSourceMetadata(parsed); })(), checkedOutBy: row.checkedOutBy || undefined, checkedOutAt: row.checkedOutAt || undefined, checkoutNodeId: row.checkoutNodeId || undefined, checkoutRunId: row.checkoutRunId || undefined, checkoutLeaseRenewedAt: row.checkoutLeaseRenewedAt || undefined, checkoutLeaseEpoch: row.checkoutLeaseEpoch ?? undefined, }; } private archiveEntryToTask(entry: ArchivedTaskEntry, slim = false): Task { return { id: entry.id, lineageId: entry.lineageId || generateTaskLineageId(), title: entry.title, description: entry.description, priority: normalizeTaskPriority(entry.priority), column: "archived", dependencies: entry.dependencies ?? [], steps: entry.steps ?? [], currentStep: entry.currentStep ?? 0, size: entry.size, reviewLevel: entry.reviewLevel, prInfo: slim ? undefined : entry.prInfo, issueInfo: slim ? undefined : entry.issueInfo, githubTracking: entry.githubTracking, sourceIssue: slim ? undefined : entry.sourceIssue, attachments: slim ? undefined : entry.attachments, comments: entry.comments, review: slim ? undefined : entry.review, log: slim ? [] : entry.log ?? [], timedExecutionMs: slim ? this.computeTimedExecutionMs(entry.log) : undefined, createdAt: entry.createdAt, updatedAt: entry.updatedAt, columnMovedAt: entry.columnMovedAt, executionStartedAt: entry.executionStartedAt, executionCompletedAt: entry.executionCompletedAt, modelPresetId: entry.modelPresetId, modelProvider: entry.modelProvider, modelId: entry.modelId, validatorModelProvider: entry.validatorModelProvider, validatorModelId: entry.validatorModelId, planningModelProvider: entry.planningModelProvider, planningModelId: entry.planningModelId, breakIntoSubtasks: entry.breakIntoSubtasks, modifiedFiles: slim ? undefined : entry.modifiedFiles, missionId: entry.missionId, sliceId: entry.sliceId, assigneeUserId: entry.assigneeUserId, }; } private summarizeAgentLog(entries: AgentLogEntry[], totalCount: number): string | undefined { if (totalCount === 0) { return undefined; } const countsByType = new Map(); const countsByAgent = new Map(); for (const entry of entries) { countsByType.set(entry.type, (countsByType.get(entry.type) ?? 0) + 1); if (entry.agent) { countsByAgent.set(entry.agent, (countsByAgent.get(entry.agent) ?? 0) + 1); } } const typeSummary = Array.from(countsByType.entries()) .map(([type, count]) => `${type}:${count}`) .join(", "); const agentSummary = Array.from(countsByAgent.entries()) .map(([agent, count]) => `${agent}:${count}`) .join(", "); const recentText = entries .slice(-5) .map((entry) => { const source = entry.agent ? `${entry.agent}/${entry.type}` : entry.type; const text = (entry.detail || entry.text || "").replace(/\s+/g, " ").trim(); const snippet = text.length > ARCHIVE_AGENT_LOG_SNIPPET_LIMIT ? `${text.slice(0, ARCHIVE_AGENT_LOG_SNIPPET_LIMIT)}...` : text; return snippet ? `${source}: ${snippet}` : source; }) .filter(Boolean) .join("\n"); return [ `Agent log entries: ${totalCount}`, typeSummary ? `Types: ${typeSummary}` : undefined, agentSummary ? `Agents: ${agentSummary}` : undefined, recentText ? `Recent entries:\n${recentText}` : undefined, ].filter(Boolean).join("\n"); } private async readPromptForArchive(taskId: string): Promise { const promptPath = join(this.taskDir(taskId), "PROMPT.md"); if (!existsSync(promptPath)) { return undefined; } return readFile(promptPath, "utf-8"); } private async buildArchivedAgentLogFields( taskId: string, mode: ArchiveAgentLogMode, ): Promise> { if (mode === "none") { return { agentLogMode: mode }; } if (mode === "full") { const entries = await this.getAgentLogs(taskId); return { agentLogMode: mode, agentLogSummary: this.summarizeAgentLog(entries, entries.length), agentLogFull: entries, }; } const [totalCount, snapshot] = await Promise.all([ this.getAgentLogCount(taskId), this.getAgentLogs(taskId, { limit: ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT }), ]); return { agentLogMode: mode, agentLogSummary: this.summarizeAgentLog(snapshot, totalCount), agentLogSnapshot: snapshot, }; } private async taskToArchiveEntry(task: Task, archivedAt: string): Promise { const settings = await this.getSettingsFast(); const agentLogMode = settings.archiveAgentLogMode ?? "compact"; const [prompt, agentLogFields] = await Promise.all([ this.readPromptForArchive(task.id), this.buildArchivedAgentLogFields(task.id, agentLogMode), ]); return { id: task.id, lineageId: task.lineageId || generateTaskLineageId(), title: task.title, description: task.description, priority: normalizeTaskPriority(task.priority), column: "archived", dependencies: task.dependencies, steps: task.steps, currentStep: task.currentStep, size: task.size, reviewLevel: task.reviewLevel, prInfo: task.prInfo, issueInfo: task.issueInfo, githubTracking: task.githubTracking, sourceIssue: task.sourceIssue, attachments: task.attachments, comments: task.comments, review: task.review, reviewState: task.reviewState, prompt, ...agentLogFields, log: [{ timestamp: archivedAt, action: "Task archived" }], createdAt: task.createdAt, updatedAt: task.updatedAt, columnMovedAt: task.columnMovedAt, executionStartedAt: task.executionStartedAt, executionCompletedAt: task.executionCompletedAt, archivedAt, modelPresetId: task.modelPresetId, modelProvider: task.modelProvider, modelId: task.modelId, validatorModelProvider: task.validatorModelProvider, validatorModelId: task.validatorModelId, planningModelProvider: task.planningModelProvider, planningModelId: task.planningModelId, breakIntoSubtasks: task.breakIntoSubtasks, baseBranch: task.baseBranch, branch: task.branch, baseCommitSha: task.baseCommitSha, mergeRetries: task.mergeRetries, error: task.error, modifiedFiles: task.modifiedFiles, missionId: task.missionId, sliceId: task.sliceId, assigneeUserId: task.assigneeUserId, }; } /** * Convert a task_documents row to a TaskDocument object. */ private rowToTaskDocument(row: TaskDocumentRow): TaskDocument { return { id: row.id, taskId: row.taskId, key: row.key, content: row.content, revision: row.revision, author: row.author, metadata: fromJson>(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, }; } /** * Convert a task_document_revisions row to a TaskDocumentRevision object. */ private rowToTaskDocumentRevision(row: TaskDocumentRevisionRow): TaskDocumentRevision { return { id: row.id, taskId: row.taskId, key: row.key, content: row.content, revision: row.revision, author: row.author, metadata: fromJson>(row.metadata), createdAt: row.createdAt, }; } private getTaskSelectClause(slim: boolean, tableAlias?: string): string { if (!slim) { return tableAlias ? `${tableAlias}.*` : "*"; } const prefix = tableAlias ? `${tableAlias}.` : ""; return [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", "worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", "attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", // `log` is fetched in slim mode so the server can aggregate // `timedExecutionMs` from `[timing] … in ms` entries before // returning. The log itself is stripped from the response — // see `listTasks()` slim post-processing. "log", ].map((column) => `${prefix}${column}`).join(", "); } /** * Sum the durations of all `[timing] … in ms` (or `… after ms`) log * entries. Returns 0 when no timing entries are present. * * Mirrors the client-side `getTimedDurationMs` so slim board listings can * report the same total-execution figure that the task detail Stats panel * computes from the full log. */ private computeTimedExecutionMs(log: import("./types.js").TaskLogEntry[] | undefined): number { if (!log || log.length === 0) return 0; let total = 0; for (const entry of log) { const action = typeof entry.action === "string" ? entry.action : ""; const outcome = typeof entry.outcome === "string" ? entry.outcome : ""; if (!action.includes("[timing]") && !outcome.includes("[timing]")) continue; const haystack = `${action}\n${outcome}`; const match = haystack.match(/(\d+(?:\.\d+)?)ms\b/i); if (!match) continue; const ms = Number(match[1]); if (Number.isFinite(ms)) total += ms; } return total; } private getTaskSelectClauseWithActivityLogLimit(limit: number): string { const columns = [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", "worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", ]; const limitedLog = ` CASE WHEN json_valid(log) AND json_array_length(log) > ${limit} THEN ( SELECT json_group_array(json(value)) FROM ( SELECT value FROM ( SELECT key, value FROM json_each(tasks.log) ORDER BY key DESC LIMIT ${limit} ) ORDER BY key ASC ) ) ELSE log END AS log `; return [...columns, limitedLog].join(", "); } private getTaskPersistValues(task: Task): unknown[] { return [ task.id, task.lineageId ?? generateTaskLineageId(), task.title ?? null, task.description, normalizeTaskPriority(task.priority), task.column, task.status ?? null, task.size ?? null, task.reviewLevel ?? null, task.currentStep || 0, task.worktree ?? null, task.blockedBy ?? null, task.paused ? 1 : 0, task.baseBranch ?? null, task.branch ?? null, task.executionStartBranch ?? null, task.baseCommitSha ?? null, task.modelPresetId ?? null, task.modelProvider ?? null, task.modelId ?? null, task.validatorModelProvider ?? null, task.validatorModelId ?? null, task.planningModelProvider ?? null, task.planningModelId ?? null, task.mergeRetries ?? null, task.workflowStepRetries ?? null, task.stuckKillCount ?? 0, task.postReviewFixCount ?? 0, task.recoveryRetryCount ?? null, task.taskDoneRetryCount ?? 0, task.verificationFailureCount ?? 0, task.mergeConflictBounceCount ?? 0, task.mergeAuditBounceCount ?? 0, task.nextRecoveryAt ?? null, task.error ?? null, task.summary ?? null, task.thinkingLevel ?? null, task.executionMode ?? null, task.tokenUsage?.inputTokens ?? null, task.tokenUsage?.outputTokens ?? null, task.tokenUsage?.cachedTokens ?? null, task.tokenUsage?.cacheWriteTokens ?? null, task.tokenUsage?.totalTokens ?? null, task.tokenUsage?.firstUsedAt ?? null, task.tokenUsage?.lastUsedAt ?? null, task.tokenBudgetSoftAlertedAt ?? null, task.tokenBudgetHardAlertedAt ?? null, toJsonNullable(task.tokenBudgetOverride), task.createdAt, task.updatedAt, task.columnMovedAt ?? null, task.executionStartedAt ?? null, task.executionCompletedAt ?? null, toJson(task.dependencies || []), toJson(task.steps || []), toJson(task.log || []), toJson(task.attachments || []), toJson(task.steeringComments || []), toJson(task.comments || []), toJsonNullable(task.review), toJsonNullable(task.reviewState), toJson(task.workflowStepResults || []), toJsonNullable(task.prInfo), toJsonNullable(task.issueInfo), toJsonNullable(task.githubTracking), task.sourceIssue?.provider ?? null, task.sourceIssue?.repository ?? null, task.sourceIssue?.externalIssueId ?? null, task.sourceIssue?.issueNumber ?? null, task.sourceIssue?.url ?? null, toJsonNullable(task.mergeDetails), task.breakIntoSubtasks ? 1 : 0, toJson(task.enabledWorkflowSteps || []), toJson(task.modifiedFiles || []), task.missionId ?? null, task.sliceId ?? null, task.scopeOverride ? 1 : null, task.scopeOverrideReason ?? null, task.assignedAgentId ?? null, task.pausedByAgentId ?? null, task.assigneeUserId ?? null, task.nodeId ?? null, task.effectiveNodeId ?? null, task.effectiveNodeSource ?? null, task.sourceType ?? null, task.sourceAgentId ?? null, task.sourceRunId ?? null, task.sourceSessionId ?? null, task.sourceMessageId ?? null, task.sourceParentTaskId ?? null, toJsonNullable(task.sourceMetadata), task.checkedOutBy ?? null, task.checkedOutAt ?? null, task.checkoutNodeId ?? null, task.checkoutRunId ?? null, task.checkoutLeaseRenewedAt ?? null, task.checkoutLeaseEpoch ?? 0, ]; } /** * Insert a brand-new task row. Create paths must use this so SQLite raises on * duplicate IDs instead of silently rewriting the existing row. */ private insertTask(task: Task): void { const values = this.getTaskPersistValues(task); const placeholders = values.map(() => "?").join(", "); this.db.prepare(` INSERT INTO tasks ( id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep, worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries, workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, nextRecoveryAt, error, summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt, executionStartedAt, executionCompletedAt, dependencies, steps, log, attachments, steeringComments, comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch ) VALUES (${placeholders}) `).run(...values); this.db.bumpLastModified(); } /** * Upsert a task to the database. Update paths intentionally retain ON CONFLICT * semantics; create paths must use insertTask() instead. */ private upsertTask(task: Task): void { const values = this.getTaskPersistValues(task); const placeholders = values.map(() => "?").join(", "); this.db.prepare(` INSERT INTO tasks ( id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep, worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries, workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, nextRecoveryAt, error, summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt, executionStartedAt, executionCompletedAt, dependencies, steps, log, attachments, steeringComments, comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch ) VALUES (${placeholders}) ON CONFLICT(id) DO UPDATE SET lineageId = excluded.lineageId, title = excluded.title, description = excluded.description, priority = excluded.priority, "column" = excluded."column", status = excluded.status, size = excluded.size, reviewLevel = excluded.reviewLevel, currentStep = excluded.currentStep, worktree = excluded.worktree, blockedBy = excluded.blockedBy, paused = excluded.paused, baseBranch = excluded.baseBranch, branch = excluded.branch, executionStartBranch = excluded.executionStartBranch, baseCommitSha = excluded.baseCommitSha, modelPresetId = excluded.modelPresetId, modelProvider = excluded.modelProvider, modelId = excluded.modelId, validatorModelProvider = excluded.validatorModelProvider, validatorModelId = excluded.validatorModelId, planningModelProvider = excluded.planningModelProvider, planningModelId = excluded.planningModelId, mergeRetries = excluded.mergeRetries, workflowStepRetries = excluded.workflowStepRetries, stuckKillCount = excluded.stuckKillCount, postReviewFixCount = excluded.postReviewFixCount, recoveryRetryCount = excluded.recoveryRetryCount, taskDoneRetryCount = excluded.taskDoneRetryCount, verificationFailureCount = excluded.verificationFailureCount, mergeConflictBounceCount = excluded.mergeConflictBounceCount, mergeAuditBounceCount = excluded.mergeAuditBounceCount, nextRecoveryAt = excluded.nextRecoveryAt, error = excluded.error, summary = excluded.summary, thinkingLevel = excluded.thinkingLevel, executionMode = excluded.executionMode, tokenUsageInputTokens = excluded.tokenUsageInputTokens, tokenUsageOutputTokens = excluded.tokenUsageOutputTokens, tokenUsageCachedTokens = excluded.tokenUsageCachedTokens, tokenUsageCacheWriteTokens = excluded.tokenUsageCacheWriteTokens, tokenUsageTotalTokens = excluded.tokenUsageTotalTokens, tokenUsageFirstUsedAt = excluded.tokenUsageFirstUsedAt, tokenUsageLastUsedAt = excluded.tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt = excluded.tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt = excluded.tokenBudgetHardAlertedAt, tokenBudgetOverride = excluded.tokenBudgetOverride, createdAt = excluded.createdAt, updatedAt = excluded.updatedAt, columnMovedAt = excluded.columnMovedAt, executionStartedAt = excluded.executionStartedAt, executionCompletedAt = excluded.executionCompletedAt, dependencies = excluded.dependencies, steps = excluded.steps, log = excluded.log, attachments = excluded.attachments, steeringComments = excluded.steeringComments, comments = excluded.comments, review = excluded.review, reviewState = excluded.reviewState, workflowStepResults = excluded.workflowStepResults, prInfo = excluded.prInfo, issueInfo = excluded.issueInfo, githubTracking = excluded.githubTracking, sourceIssueProvider = excluded.sourceIssueProvider, sourceIssueRepository = excluded.sourceIssueRepository, sourceIssueExternalIssueId = excluded.sourceIssueExternalIssueId, sourceIssueNumber = excluded.sourceIssueNumber, sourceIssueUrl = excluded.sourceIssueUrl, mergeDetails = excluded.mergeDetails, breakIntoSubtasks = excluded.breakIntoSubtasks, enabledWorkflowSteps = excluded.enabledWorkflowSteps, modifiedFiles = excluded.modifiedFiles, missionId = excluded.missionId, sliceId = excluded.sliceId, scopeOverride = excluded.scopeOverride, scopeOverrideReason = excluded.scopeOverrideReason, assignedAgentId = excluded.assignedAgentId, pausedByAgentId = excluded.pausedByAgentId, assigneeUserId = excluded.assigneeUserId, nodeId = excluded.nodeId, effectiveNodeId = excluded.effectiveNodeId, effectiveNodeSource = excluded.effectiveNodeSource, sourceType = excluded.sourceType, sourceAgentId = excluded.sourceAgentId, sourceRunId = excluded.sourceRunId, sourceSessionId = excluded.sourceSessionId, sourceMessageId = excluded.sourceMessageId, sourceParentTaskId = excluded.sourceParentTaskId, sourceMetadata = excluded.sourceMetadata, checkedOutBy = excluded.checkedOutBy, checkedOutAt = excluded.checkedOutAt, checkoutNodeId = excluded.checkoutNodeId, checkoutRunId = excluded.checkoutRunId, checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt, checkoutLeaseEpoch = excluded.checkoutLeaseEpoch `).run(...this.getTaskPersistValues(task)); this.db.bumpLastModified(); } private isTaskIdConflictError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks\.id|PRIMARY KEY constraint failed: tasks\.id/i.test(message); } private logTaskCreateConflict(task: Task, operation: string, error: unknown): void { storeLog.error("Refused colliding task create", { phase: "task-create:id-conflict", operation, taskId: task.id, column: task.column, sourceType: task.sourceType, error: error instanceof Error ? error.message : String(error), }); } private insertTaskWithFtsRecovery(task: Task, operation: string): void { const normalizeConflict = (error: unknown): never => { this.logTaskCreateConflict(task, operation, error); throw new Error(`Task ID already exists: ${task.id}`); }; try { this.insertTask(task); return; } catch (error) { if (this.isTaskIdConflictError(error)) { normalizeConflict(error); } if (!this.db.isFts5CorruptionError(error)) { throw error; } console.warn(`[fusion:store] FTS5 corruption detected during insert for task ${task.id}; rebuilding index and retrying once`); try { this.db.rebuildFts5Index(); } catch (rebuildError) { console.warn("[fusion:store] FTS5 rebuild failed; propagating original insert error", rebuildError); throw error; } try { this.insertTask(task); } catch (retryError) { if (this.isTaskIdConflictError(retryError)) { normalizeConflict(retryError); } console.warn("[fusion:store] Insert retry after FTS5 rebuild failed; propagating original insert error", retryError); throw error; } } } private upsertTaskWithFtsRecovery(task: Task): void { try { this.upsertTask(task); return; } catch (error) { if (!this.db.isFts5CorruptionError(error)) { throw error; } console.warn(`[fusion:store] FTS5 corruption detected during upsert for task ${task.id}; rebuilding index and retrying once`); try { this.db.rebuildFts5Index(); } catch (rebuildError) { console.warn("[fusion:store] FTS5 rebuild failed; propagating original upsert error", rebuildError); throw error; } try { this.upsertTask(task); } catch (retryError) { console.warn("[fusion:store] Upsert retry after FTS5 rebuild failed; propagating original upsert error", retryError); throw error; } } } /** * Read a task from SQLite by ID. */ private readTaskFromDb(id: string, options?: { activityLogLimit?: number }): Task | undefined { const selectClause = options?.activityLogLimit ? this.getTaskSelectClauseWithActivityLogLimit(options.activityLogLimit) : "*"; const row = this.db.prepare(`SELECT ${selectClause} FROM tasks WHERE id = ?`).get(id) as unknown as TaskRow | undefined; if (!row) return undefined; return this.rowToTask(row); } private isTaskIdPresentInArchivedTasksTable(id: string): boolean { try { const row = this.db.prepare("SELECT 1 as found FROM archivedTasks WHERE id = ? LIMIT 1").get(id) as { found?: number } | undefined; return row?.found === 1; } catch { return false; } } private taskIdExistsAnywhere(id: string): boolean { if (this.readTaskFromDb(id)) { return true; } if (this.isTaskIdPresentInArchivedTasksTable(id)) { return true; } return this.archiveDb.get(id) !== undefined; } private assertTaskIdAvailable(id: string): void { if (this.taskIdExistsAnywhere(id)) { throw new Error(`Task ID already exists: ${id}`); } } private isTaskArchived(id: string): boolean { const row = this.db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(id) as { column: Column } | undefined; if (row) { return row.column === "archived"; } return this.archiveDb.get(id) !== undefined; } /** * Return the ids of live tasks whose `dependencies` array contains `id`. * * Uses a SQL LIKE probe as a cheap pre-filter then parses the JSON column * to rule out false positives (substring matches on similar ids, matches * inside escaped strings, etc.). */ private findLiveDependents(id: string): string[] { const rows = this.db .prepare(`SELECT id, dependencies FROM tasks WHERE dependencies LIKE ? AND id != ?`) .all(`%${id}%`, id) as Array<{ id: string; dependencies: string | null }>; const dependents: string[] = []; for (const row of rows) { if (!row.dependencies) continue; try { const deps = JSON.parse(row.dependencies) as unknown; if (Array.isArray(deps) && deps.includes(id)) { dependents.push(row.id); } } catch { // Malformed JSON — skip; nothing we can verify. } } return dependents; } /** * Set up event listeners for activity logging. * Call after init() to record task lifecycle events. * * Idempotent — repeated calls are no-ops. Without this guard, each duplicate * call double-registers handlers, causing the activity log to record every * `task:created` / `task:moved` event N times where N = number of init() calls. */ private setupActivityLogListeners(): void { if (this.activityListenersWired) return; this.activityListenersWired = true; // Task created this.on("task:created", (task) => { this.recordActivityFromListener( { type: "task:created", taskId: task.id, taskTitle: task.title, details: `Task ${task.id} created${task.title ? `: ${task.title}` : ""}`, }, "task:created", ); }); // Task moved this.on("task:moved", (data) => { this.recordActivityFromListener( { type: "task:moved", taskId: data.task.id, taskTitle: data.task.title, details: `Task ${data.task.id} moved: ${data.from} → ${data.to}`, metadata: { from: data.from, to: data.to }, }, "task:moved", ); }); // Task merged this.on("task:merged", (result) => { const status = result.merged ? "successfully merged" : "merge attempted"; this.recordActivityFromListener( { type: "task:merged", taskId: result.task.id, taskTitle: result.task.title, details: `Task ${result.task.id} ${status} to main`, metadata: { merged: result.merged, branch: result.branch }, }, "task:merged", ); }); // Task updated (check for failures) this.on("task:updated", (task) => { if (task.status === "failed") { this.recordActivityFromListener( { type: "task:failed", taskId: task.id, taskTitle: task.title, details: `Task ${task.id} failed${task.error ? `: ${task.error}` : ""}`, metadata: task.error ? { error: task.error } : undefined, }, "task:updated", ); } }); // Settings updated (log important changes) this.on("settings:updated", (data) => { const importantChanges: string[] = []; if (data.settings.ntfyEnabled !== data.previous.ntfyEnabled) { importantChanges.push(`ntfy ${data.settings.ntfyEnabled ? "enabled" : "disabled"}`); } if (data.settings.ntfyTopic !== data.previous.ntfyTopic) { importantChanges.push(`ntfy topic changed to ${data.settings.ntfyTopic}`); } if (data.settings.globalPause !== data.previous.globalPause) { importantChanges.push(`global pause ${data.settings.globalPause ? "enabled" : "disabled"}`); } if (data.settings.enginePaused !== data.previous.enginePaused) { importantChanges.push(`engine pause ${data.settings.enginePaused ? "enabled" : "disabled"}`); } if (importantChanges.length > 0) { this.recordActivityFromListener( { type: "settings:updated", details: `Settings updated: ${importantChanges.join(", ")}`, metadata: { changes: importantChanges }, }, "settings:updated", ); } }); // Task deleted this.on("task:deleted", (task) => { this.recordActivityFromListener( { type: "task:deleted", taskId: task.id, taskTitle: task.title, details: `Task ${task.id} deleted${task.title ? `: ${task.title}` : ""}`, }, "task:deleted", ); }); } private recordActivityFromListener( entry: Omit, sourceEvent: string, ): void { this.recordActivity(entry).catch((err) => { storeLog.warn("Activity logging listener failed", { sourceEvent, type: entry.type, taskId: entry.taskId, error: err instanceof Error ? err.message : String(err), }); }); } /** * Serialize all mutations to config.json by chaining promises. * Concurrent callers will queue behind each other, preventing * lost-update races on the nextId counter. */ private withConfigLock(fn: () => Promise): Promise { let resolve: () => void; const next = new Promise((r) => { resolve = r; }); const prev = this.configLock; this.configLock = next; return prev.then(async () => { try { return await fn(); } finally { resolve!(); } }); } /** * Serialize all mutations to a given task's task.json by chaining promises * per task ID. Concurrent callers for the same ID will queue behind each other. */ private withWorktreeAllocationLock(fn: () => Promise): Promise { let resolve: () => void; const next = new Promise((r) => { resolve = r; }); const prev = this.worktreeAllocationLock; this.worktreeAllocationLock = next; return prev.then(async () => { try { return await fn(); } finally { resolve!(); } }); } private withTaskLock(id: string, fn: () => Promise): Promise { const prev = this.taskLocks.get(id) ?? Promise.resolve(); let resolve: () => void; const next = new Promise((r) => { resolve = r; }); this.taskLocks.set(id, next); return prev.then(async () => { try { return await fn(); } finally { if (this.taskLocks.get(id) === next) { this.taskLocks.delete(id); } resolve!(); } }); } /** * Read a task from SQLite by ID (extracted from dir path for backward compat). * Falls back to file-based reading if not in DB. */ private async readTaskJson(dir: string): Promise { // Extract task ID from directory path (handles both / and \ separators) const parts = dir.replace(/\\/g, "/").split("/"); const id = parts[parts.length - 1]; // Try SQLite first const task = this.readTaskFromDb(id); if (task) return task; // Fallback to file-based reading (for legacy compatibility) const filePath = join(dir, "task.json"); const raw = await readFile(filePath, "utf-8"); try { const fileTask = JSON.parse(raw) as Task; if (!Array.isArray(fileTask.log)) fileTask.log = []; if (!Array.isArray(fileTask.dependencies)) fileTask.dependencies = []; if (!Array.isArray(fileTask.steps)) fileTask.steps = []; fileTask.priority = normalizeTaskPriority(fileTask.priority); return fileTask; } catch (err) { throw new Error( `Failed to parse task.json at ${filePath}: ${(err as Error).message}`, ); } } private async writeTaskJsonFile(dir: string, task: Task): Promise { const taskJsonPath = join(dir, "task.json"); // Use a unique tmp filename per write so concurrent writers to the same task // don't race on a shared `task.json.tmp` (one rename consumes it, the other // ENOENTs). See FN-4122/FN-4123/FN-4148 for the reproducer. const tmpPath = join(dir, `task.json.${process.pid}.${randomUUID()}.tmp`); this.suppressWatcher(taskJsonPath); await mkdir(dir, { recursive: true }); await writeFile(tmpPath, JSON.stringify(task)); try { await rename(tmpPath, taskJsonPath); } catch (err) { // Best-effort cleanup of our tmp on rename failure so we don't leave // orphaned `task.json.*.tmp` files behind. try { await unlink(tmpPath); } catch { // ignore — tmp may already be gone } throw err; } } /** * Write a brand-new task to SQLite (primary store) and also write task.json to disk * for backward compatibility and debugging. Create paths must call this variant * so duplicate IDs fail safely instead of overwriting existing rows. */ private async atomicCreateTaskJson(dir: string, task: Task, operation: string): Promise { this.insertTaskWithFtsRecovery(task, operation); await this.writeTaskJsonFile(dir, task); } /** * Write an existing task to SQLite (primary store) and also write task.json to disk * for backward compatibility and debugging. */ private async atomicWriteTaskJson(dir: string, task: Task): Promise { this.upsertTaskWithFtsRecovery(task); // Also write to disk for backward compatibility await this.writeTaskJsonFile(dir, task); } /** * Write a task to SQLite and optionally record a run-audit event, all in a single * SQLite transaction. If the audit insert fails, the task mutation is rolled back. * * @param dir - Task directory path * @param task - Task to write * @param auditInput - Optional audit event input to record atomically with the task write */ private async atomicWriteTaskJsonWithAudit( dir: string, task: Task, auditInput?: RunAuditEventInput, ): Promise { this.db.transactionImmediate(() => { // Upsert the task this.upsertTaskWithFtsRecovery(task); // Optionally record the audit event in the same transaction if (auditInput) { const eventId = randomUUID(); const timestamp = auditInput.timestamp ?? new Date().toISOString(); this.db.prepare(` INSERT INTO runAuditEvents ( id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( eventId, timestamp, auditInput.taskId ?? null, auditInput.agentId, auditInput.runId, auditInput.domain, auditInput.mutationType, auditInput.target, toJsonNullable(auditInput.metadata), ); } }); // File writes are not part of the SQLite transaction await this.writeTaskJsonFile(dir, task); } /** * Get merged settings: global defaults ← global user prefs ← project overrides. * * Returns the combined view that most consumers should use. Project-level * values in `.fusion/config.json` override global values from `~/.fusion/settings.json`. * * */ async getSettings(): Promise { const [globalSettings, config] = await Promise.all([ this.globalSettingsStore.getSettings(), this.readConfig(), ]); // Strip global-only keys from project-level settings so stale project-scoped // values don't override the correct global value during the spread merge. const projectSettings = Object.fromEntries( Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalOnlySettingsKey(key)), ); return canonicalizeSettings({ ...DEFAULT_SETTINGS, ...globalSettings, ...projectSettings, }); } /** * Fast-path settings read that skips the expensive workflow steps query. * * This method reads only the `settings` column from the SQLite config row * (avoiding `readConfig()` which always calls `listWorkflowSteps()`), and * uses the cached global settings from `GlobalSettingsStore`. Use this for * read-heavy paths like the settings page that don't need workflow steps. * * Note: Do NOT use this method when you need workflow steps — use `getSettings()` instead. * * */ async getSettingsFast(): Promise { const [globalSettings, row] = await Promise.all([ this.globalSettingsStore.getSettings(), this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined, ]); const raw = row?.settings ? fromJson(row.settings) : undefined; // Strip global-only keys from the project-level row so stale project-scoped // values (e.g. an empty experimentalFeatures={}) don't override the correct // global value during the spread merge below. getSettingsByScopeFast() has // always done this; getSettingsFast() was missing the filter. const projectSettings: Partial | undefined = raw ? (Object.fromEntries( Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)), ) as Partial) : undefined; return canonicalizeSettings({ ...DEFAULT_SETTINGS, ...globalSettings, ...projectSettings, }); } /** * Get settings separated by scope. Returns both the global and * project-level settings independently (useful for the UI to show * which scope a value comes from). * * */ async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial }> { const [globalSettings, config] = await Promise.all([ this.globalSettingsStore.getSettings(), this.readConfig(), ]); // Extract only project-level keys from config.settings const projectSettings: Partial = {}; if (config.settings) { for (const key of Object.keys(config.settings)) { if (!isGlobalOnlySettingsKey(key)) { (projectSettings as Record)[key] = (config.settings as Record)[key]; } } } // Apply canonicalization to project settings and keep upgrade-safe // default fallback behavior for legacy rows that omit this key. const canonicalizedProject = canonicalizeSettings(projectSettings as Settings); if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } return { global: globalSettings, project: canonicalizedProject }; } /** * Fast-path version of `getSettingsByScope()` that skips the expensive * `listWorkflowSteps()` query. * * This method reads only the `settings` column from the SQLite config row * (avoiding `readConfig()` which always calls `listWorkflowSteps()`), and * uses the cached global settings from `GlobalSettingsStore`. Use this for * read-heavy paths like the settings page that don't need workflow steps. * * */ async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial }> { const [globalSettings, row] = await Promise.all([ this.globalSettingsStore.getSettings(), this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined, ]); const projectSettings = row?.settings ? fromJson(row.settings) : undefined; // Extract only project-level keys from config.settings const projectScoped: Partial = {}; if (projectSettings) { for (const key of Object.keys(projectSettings)) { if (!isGlobalOnlySettingsKey(key)) { (projectScoped as Record)[key] = (projectSettings as Record)[key]; } } } // Apply canonicalization and keep upgrade-safe default fallback behavior // for legacy rows that omit this key. const canonicalizedProject = canonicalizeSettings(projectScoped as Settings); if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } return { global: globalSettings, project: canonicalizedProject }; } /** * Update project-level settings in `.fusion/config.json`. * * Accepts `Partial` for backward compatibility. Any global-only * fields in the patch are silently filtered out — they will not be persisted * to the project config. Use `updateGlobalSettings()` for global fields. */ async updateSettings(patch: Partial): Promise { // Filter out global-only fields — they should go through updateGlobalSettings() const projectPatch: Partial = {}; for (const [key, value] of Object.entries(patch)) { if (!isGlobalOnlySettingsKey(key)) { (projectPatch as Record)[key] = value; } } return this.withConfigLock(async () => { const config = this.readConfigFast(); // Handle null values as "delete this key from settings" // This allows the frontend to explicitly clear a setting by sending null // (since JSON.stringify drops undefined keys, we use null as a sentinel) // Handle special null-as-delete semantics for promptOverrides const incomingPromptOverrides = (projectPatch as Record)["promptOverrides"]; if (incomingPromptOverrides === null) { // promptOverrides: null → clear the entire promptOverrides object delete (config.settings as unknown as Record)["promptOverrides"]; delete (projectPatch as Record)["promptOverrides"]; } else if ( incomingPromptOverrides !== undefined && typeof incomingPromptOverrides === "object" && incomingPromptOverrides !== null ) { // promptOverrides: { key: value } → merge with existing, treating null values as delete const incomingMap = incomingPromptOverrides as Record; const existingMap = ((config.settings as unknown as Record)["promptOverrides"] as Record) ?? {}; const mergedMap: Record = { ...existingMap }; for (const [key, value] of Object.entries(incomingMap)) { if (value === null) { // null → delete this specific key delete mergedMap[key]; } else if (typeof value === "string" && value !== "") { // non-empty string → set this key // Empty strings are treated as "clear" and not stored mergedMap[key] = value; } // Empty strings are silently ignored (treated as "clear") } // If merged map is empty, remove the entire promptOverrides if (Object.keys(mergedMap).length === 0) { delete (config.settings as unknown as Record)["promptOverrides"]; delete (projectPatch as Record)["promptOverrides"]; } else { (config.settings as unknown as Record)["promptOverrides"] = mergedMap; (projectPatch as Record)["promptOverrides"] = mergedMap; } } // Handle null values for other top-level keys (non-promptOverrides) for (const key of Object.keys(projectPatch)) { if ((projectPatch as Record)[key] === null) { delete (config.settings as unknown as Record)[key]; delete (projectPatch as Record)[key]; } } const globalSettings = await this.globalSettingsStore.getSettings(); const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings; const updatedProjectSettings = { ...config.settings, ...projectPatch }; config.settings = updatedProjectSettings as Settings; await this.writeConfig(config); const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged }); // Bootstrap project memory file when memory is toggled on if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) { try { // Use backend-aware bootstrap to honor memoryBackendType setting await ensureMemoryFileWithBackend(this.rootDir, updatedMerged); } catch (err) { // Non-fatal — memory bootstrap failure should not block settings update storeLog.warn("Project-memory bootstrap failed after memory toggle-on", { phase: "updateSettings:memory-toggle-on", rootDir: this.rootDir, error: err instanceof Error ? err.message : String(err), }); } } return updatedMerged; }); } /** * Update global (user-level) settings in `~/.fusion/settings.json`. * * These settings persist across all fn projects for the current user. * Only fields defined in `GlobalSettings` are accepted. */ async updateGlobalSettings(patch: Partial): Promise { // Read previous state BEFORE writing so the diff is correct const previousGlobal = await this.globalSettingsStore.getSettings(); const config = this.readConfigFast(); const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings; const globalPatch: Partial = { ...patch }; // Handle deep merge + targeted null clear semantics for remoteAccess const incomingRemoteAccess = (globalPatch as Record)["remoteAccess"]; if (incomingRemoteAccess === null) { (globalPatch as Record)["remoteAccess"] = null; } else if (isPlainObject(incomingRemoteAccess)) { const existingRemoteAccess = (previousGlobal as Record)["remoteAccess"]; const mergedRemoteAccess = deepMergeWithNullDelete(existingRemoteAccess, incomingRemoteAccess); if (mergedRemoteAccess === undefined) { (globalPatch as Record)["remoteAccess"] = null; } else { (globalPatch as Record)["remoteAccess"] = mergedRemoteAccess; } } // Handle experimentalFeatures merging (similar to promptOverrides) const incomingExperimentalFeatures = (globalPatch as Record)["experimentalFeatures"]; if (incomingExperimentalFeatures === null) { (globalPatch as Record)["experimentalFeatures"] = null; } else if ( incomingExperimentalFeatures !== undefined && typeof incomingExperimentalFeatures === "object" && !Array.isArray(incomingExperimentalFeatures) ) { const incomingMap = incomingExperimentalFeatures as Record; const existingMap = ((previousGlobal as Record)["experimentalFeatures"] as Record) ?? {}; const mergedMap: Record = { ...existingMap }; for (const [key, value] of Object.entries(incomingMap)) { if (value === null) { delete mergedMap[key]; } else if (typeof value === "boolean") { mergedMap[key] = value; } } (globalPatch as Record)["experimentalFeatures"] = mergedMap; } const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch); const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings; // Emit settings:updated so SSE listeners pick up the change this.emit("settings:updated", { settings: merged, previous }); return merged; } /** * Get the GlobalSettingsStore instance (used by API routes). */ getGlobalSettingsStore(): GlobalSettingsStore { return this.globalSettingsStore; } private async readConfig(): Promise { const row = this.db.prepare("SELECT * FROM config WHERE id = 1").get() as unknown as ConfigRow | undefined; if (!row) { return { nextId: 1 }; } const config: BoardConfig = { nextId: row.nextId || 1, settings: fromJson(row.settings), }; // Backward-compatibility for internal callers/tests that still access these fields. // Keep them non-enumerable so config.json writes don't include workflow steps. const workflowSteps = this.listWorkflowSteps(); Object.defineProperty(config, "workflowSteps", { value: await workflowSteps, writable: true, configurable: true, enumerable: false, }); Object.defineProperty(config, "nextWorkflowStepId", { value: row.nextWorkflowStepId || 1, writable: true, configurable: true, enumerable: false, }); return config; } /** * Fast-path config read that skips the expensive listWorkflowSteps() query. * Returns only the core config fields needed for config.json serialization. */ private readConfigFast(): BoardConfig { const row = this.db.prepare("SELECT * FROM config WHERE id = 1").get() as ConfigRow | undefined; if (!row) { return { nextId: 1 }; } return { nextId: row.nextId || 1, settings: fromJson(row.settings), }; } private serializeConfigForDisk(config: BoardConfig): string { const { nextId: _deprecatedNextId, ...configForDisk } = config as BoardConfig & { nextId?: number }; return JSON.stringify(configForDisk, null, 2); } private async writeConfig( config: BoardConfig, options?: { nextWorkflowStepId?: number }, ): Promise { const now = new Date().toISOString(); const row = this.db .prepare("SELECT nextWorkflowStepId FROM config WHERE id = 1") .get() as { nextWorkflowStepId?: number } | undefined; const nextWorkflowStepId = options?.nextWorkflowStepId ?? row?.nextWorkflowStepId ?? 1; const legacyWorkflowSteps = (config as { workflowSteps?: unknown }).workflowSteps; const workflowStepsJson = Array.isArray(legacyWorkflowSteps) ? JSON.stringify(legacyWorkflowSteps) : "[]"; // `config.nextId` is deprecated legacy state. Preserve the existing column // value for one release, but stop writing new values so distributed_task_id_state // remains the sole active allocator counter. this.db.prepare( `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET nextWorkflowStepId = excluded.nextWorkflowStepId, settings = excluded.settings, workflowSteps = excluded.workflowSteps, updatedAt = excluded.updatedAt`, ).run( nextWorkflowStepId, JSON.stringify(config.settings || {}), workflowStepsJson, now, ); this.db.bumpLastModified(); // Also write config.json to disk for backward compatibility try { const tmpPath = this.configPath + ".tmp"; await writeFile(tmpPath, this.serializeConfigForDisk(config)); await rename(tmpPath, this.configPath); } catch (err) { // Best-effort: SQLite is the primary store storeLog.warn("Backward-compat config.json sync failed after config write", { phase: "writeConfig:disk-sync", configPath: this.configPath, error: err instanceof Error ? err.message : String(err), }); } } async resolveLocalNodeIdForTaskAllocation(): Promise { if (process.env.VITEST === "true") { return "local"; } const central = new CentralCore(); await central.init(); try { const nodes = await central.listNodes(); return resolveLocalNodeId(nodes.map((node) => ({ id: node.id, type: node.type }))); } catch { return "local"; } finally { await central.close(); } } private async createTaskWithDistributedReservation( input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise; }, ): Promise { const settings = await this.getSettingsFast(); const prefix = (settings.taskPrefix || "KB").trim().toUpperCase(); const allocator = this.getDistributedTaskIdAllocator(); const nodeId = await this.resolveLocalNodeIdForTaskAllocation(); const reservation = await allocator.reserveDistributedTaskId({ prefix, nodeId, }); let createdTask: Task | null = null; try { createdTask = options?.createTaskWithId ? await options.createTaskWithId(reservation.taskId) : await this.createTaskWithReservedId(input, { taskId: reservation.taskId }); await allocator.commitDistributedTaskIdReservation({ reservationId: reservation.reservationId, nodeId, }); return createdTask; } catch (error) { await allocator.abortDistributedTaskIdReservation({ reservationId: reservation.reservationId, nodeId, reason: "failed-create", }).catch(() => undefined); throw error; } } private taskDir(id: string): string { return join(this.tasksDir, id); } private getBuiltInWorkflowTemplate(templateId: string): import("./types.js").WorkflowStepTemplate | undefined { return WORKFLOW_STEP_TEMPLATES.find((template) => template.id === templateId); } private toBuiltInWorkflowStep(template: import("./types.js").WorkflowStepTemplate): import("./types.js").WorkflowStep { const now = new Date().toISOString(); return { id: template.id, templateId: template.id, name: template.name, description: template.description, mode: "prompt", phase: "pre-merge", gateMode: "advisory", prompt: template.prompt, toolMode: template.toolMode || "readonly", enabled: true, createdAt: now, updatedAt: now, }; } private toStoredWorkflowStep(row: { id: string; templateId: string | null; name: string; description: string; mode: string; phase: string | null; gateMode: string | null; prompt: string; toolMode: string | null; scriptName: string | null; enabled: number; defaultOn: number | null; modelProvider: string | null; modelId: string | null; createdAt: string; updatedAt: string; }): import("./types.js").WorkflowStep { return { id: row.id, templateId: row.templateId ?? undefined, name: row.name, description: row.description, mode: row.mode === "script" ? "script" : "prompt", phase: row.phase === "post-merge" ? "post-merge" : "pre-merge", gateMode: row.gateMode === "advisory" || row.gateMode === "gate" ? row.gateMode : (row.mode === "script" ? "gate" : "advisory"), prompt: row.prompt || "", toolMode: row.toolMode === "coding" || row.toolMode === "readonly" ? row.toolMode : undefined, scriptName: row.scriptName ?? undefined, enabled: Boolean(row.enabled), defaultOn: row.defaultOn === null || row.defaultOn === undefined ? undefined : Boolean(row.defaultOn), modelProvider: row.modelProvider ?? undefined, modelId: row.modelId ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } private getLegacyWorkflowStepSnapshot(id: string, templateId?: string): Record | undefined { const row = this.db .prepare("SELECT workflowSteps FROM config WHERE id = 1") .get() as { workflowSteps?: string | null } | undefined; const legacySteps = fromJson>>(row?.workflowSteps); if (!Array.isArray(legacySteps)) { return undefined; } return legacySteps.find((legacy) => { if (!legacy || typeof legacy !== "object") return false; if (legacy.id === id) return true; return Boolean(templateId && legacy.templateId === templateId); }); } private applyLegacyWorkflowStepOverrides(step: import("./types.js").WorkflowStep): import("./types.js").WorkflowStep { const legacy = this.getLegacyWorkflowStepSnapshot(step.id, step.templateId); if (!legacy) { return step; } const normalized = { ...step }; if (!Object.prototype.hasOwnProperty.call(legacy, "mode")) { normalized.mode = "prompt"; } if (!Object.prototype.hasOwnProperty.call(legacy, "phase")) { normalized.phase = undefined; } if (!Object.prototype.hasOwnProperty.call(legacy, "gateMode")) { normalized.gateMode = normalized.mode === "script" ? "gate" : "advisory"; } return normalized; } private async ensureWorkflowStepForTemplate(templateId: string): Promise { const template = this.getBuiltInWorkflowTemplate(templateId); if (!template) { throw new Error(`Workflow step template '${templateId}' not found`); } const existing = await this.getWorkflowStep(templateId); if (existing && existing.id !== templateId) { return existing; } const allSteps = await this.listWorkflowSteps(); const byName = allSteps.find((step) => step.name.toLowerCase() === template.name.toLowerCase()); if (byName) { return byName; } return this.createWorkflowStep({ templateId: template.id, name: template.name, description: template.description, mode: "prompt", phase: "pre-merge", prompt: template.prompt, gateMode: "advisory", toolMode: template.toolMode || "readonly", enabled: true, }); } private async resolveEnabledWorkflowSteps(stepIds?: string[]): Promise { if (!stepIds?.length) return undefined; const resolved: string[] = []; const seen = new Set(); for (const rawId of stepIds) { const stepId = rawId.trim(); if (!stepId) continue; if (stepId.startsWith("plugin:")) { if (!seen.has(stepId)) { seen.add(stepId); resolved.push(stepId); } continue; } const template = this.getBuiltInWorkflowTemplate(stepId); const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId; if (!seen.has(resolvedId)) { seen.add(resolvedId); resolved.push(resolvedId); } } return resolved.length > 0 ? resolved : undefined; } async createTask( input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; } ): Promise { if (!input.description?.trim()) { throw new Error("Description is required and cannot be empty"); } // Determine if we should try to summarize the title const title = input.title?.trim() || undefined; const shouldSummarize = !title && input.description.length > 200 && (input.summarize === true || options?.settings?.autoSummarizeTitles === true); // Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) : undefined; if (input.enabledWorkflowSteps === undefined) { try { const allSteps = await this.listWorkflowSteps(); const defaultOnSteps = allSteps .filter((ws) => ws.enabled && ws.defaultOn) .map((ws) => ws.id); if (defaultOnSteps.length > 0) { resolvedWorkflowSteps = defaultOnSteps; } } catch (err) { storeLog.warn("Failed to auto-apply default workflow steps during task creation; auto-defaulting skipped", { phase: "createTask:workflow-auto-default", skippedAutoDefaulting: true, error: err instanceof Error ? err.message : String(err), descriptionLength: input.description.length, }); } } else if (input.enabledWorkflowSteps.length === 0) { resolvedWorkflowSteps = undefined; } const task = await this.createTaskWithDistributedReservation(input, { createTaskWithId: async (taskId) => { if (input.dependencies?.includes(taskId)) { throw new Error(`Task ${taskId} cannot depend on itself`); } return this._createTaskInternal(input, title, resolvedWorkflowSteps, taskId); }, }); if (shouldSummarize && options?.onSummarize) { const id = task.id; Promise.resolve().then(async () => { try { const generatedTitle = await options.onSummarize!(input.description); const normalizedTitle = sanitizeTitle(generatedTitle); if (normalizedTitle) { const currentTask = this.readTaskFromDb(id); if (currentTask && !currentTask.title) { await this.updateTask(id, { title: normalizedTitle }); } } } catch (err) { const autoEnabled = options?.settings?.autoSummarizeTitles === true; const errorMessage = err instanceof Error ? err.message : String(err); storeLog.warn( `Title summarization failed for task ${id}: ${errorMessage} (desc length: ${input.description.length}, auto-summarize: ${autoEnabled})`, { taskId: id, descriptionLength: input.description.length, autoSummarizeEnabled: autoEnabled, error: errorMessage, }, ); } }).catch((err) => { const autoEnabled = options?.settings?.autoSummarizeTitles === true; storeLog.error("Unexpected title summarization promise-chain failure", { taskId: id, descriptionLength: input.description.length, autoSummarizeEnabled: autoEnabled, error: err instanceof Error ? err.message : String(err), }); }); } return task; } async createTaskWithReservedId( input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; }, ): Promise { if (!input.description?.trim()) { throw new Error("Description is required and cannot be empty"); } const id = options.taskId.trim(); if (!id) { throw new Error("taskId is required"); } if (input.dependencies?.includes(id)) { throw new Error(`Task ${id} cannot depend on itself`); } this.assertTaskIdAvailable(id); const title = input.title?.trim() || undefined; let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) : undefined; if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) { try { const allSteps = await this.listWorkflowSteps(); const defaultOnSteps = allSteps .filter((ws) => ws.enabled && ws.defaultOn) .map((ws) => ws.id); if (defaultOnSteps.length > 0) { resolvedWorkflowSteps = defaultOnSteps; } } catch (err) { storeLog.warn("Failed to auto-apply default workflow steps during reserved task creation; auto-defaulting skipped", { phase: "createTaskWithReservedId:workflow-auto-default", skippedAutoDefaulting: true, error: err instanceof Error ? err.message : String(err), descriptionLength: input.description.length, }); } } else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) { resolvedWorkflowSteps = undefined; } return this._createTaskInternal(input, title, resolvedWorkflowSteps, id, { createdAt: options.createdAt, updatedAt: options.updatedAt, promptOverride: options.prompt, }); } async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise { const existing = this.readTaskFromDb(payload.taskId); if (existing) { const existingDetail = await this.getTask(payload.taskId); if (taskMatchesReplicatedCreate(existingDetail, payload)) { return { task: existingDetail, applied: false }; } throw replicationCollisionError(payload.taskId); } const task = await this.createTaskWithReservedId(payload.input, { taskId: payload.taskId, createdAt: payload.createdAt, updatedAt: payload.updatedAt, prompt: payload.prompt, applyDefaultWorkflowSteps: false, }); return { task, applied: true }; } /** * Internal helper for task creation. Used by createTask() and potentially other * internal methods that need to create tasks without triggering summarization. */ private async _createTaskInternal( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; }, ): Promise { const now = options?.createdAt ?? new Date().toISOString(); const task: Task = { id, lineageId: input.lineageId ?? generateTaskLineageId(), title, description: input.description, priority: normalizeTaskPriority(input.priority), tokenUsage: input.tokenUsage, sourceIssue: input.sourceIssue, githubTracking: input.githubTracking, sourceType: input.source?.sourceType ?? "unknown", sourceAgentId: input.source?.sourceAgentId, sourceRunId: input.source?.sourceRunId, sourceSessionId: input.source?.sourceSessionId, sourceMessageId: input.source?.sourceMessageId, sourceParentTaskId: input.source?.sourceParentTaskId, sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext), branchContext: input.branchContext, column: input.column || "triage", dependencies: input.dependencies || [], breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, enabledWorkflowSteps: resolvedWorkflowSteps, modelPresetId: input.modelPresetId, assignedAgentId: input.assignedAgentId, assigneeUserId: input.assigneeUserId, scopeOverride: input.scopeOverride === true ? true : undefined, scopeOverrideReason: input.scopeOverrideReason, nodeId: input.nodeId, modelProvider: input.modelProvider, modelId: input.modelId, validatorModelProvider: input.validatorModelProvider, validatorModelId: input.validatorModelId, planningModelProvider: input.planningModelProvider, planningModelId: input.planningModelId, thinkingLevel: input.thinkingLevel, reviewLevel: input.reviewLevel, executionMode: input.executionMode, baseBranch: input.baseBranch, branch: input.branch, missionId: input.missionId, sliceId: input.sliceId, steps: [], currentStep: 0, log: [{ timestamp: now, action: "Task created" }], columnMovedAt: now, createdAt: now, updatedAt: options?.updatedAt ?? now, }; this.assertTaskIdAvailable(id); const dir = this.taskDir(id); await this.atomicCreateTaskJson(dir, task, "createTask"); // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); const prompt = options?.promptOverride ?? (task.column === "triage" ? buildBootstrapPrompt(id, task.title, task.description) : this.generateSpecifiedPrompt(task)); const validation = validateFileScopeInPromptContent(prompt); if (validation.invalid.length > 0) { if (this.isWatching) this.taskCache.delete(id); this.deleteTaskById(id); const { rm } = await import("node:fs/promises"); if (existsSync(dir)) { await rm(dir, { recursive: true, force: true }); } throw new InvalidFileScopeError(id, validation.invalid); } await mkdir(dir, { recursive: true }); await writeFile(join(dir, "PROMPT.md"), prompt); this.emit("task:created", task); return task; } /** * Duplicate an existing task, creating a fresh copy in triage. * Copies title and description with source reference, but resets all * execution state. The new task will be re-specified by the AI. */ async duplicateTask(id: string): Promise { const sourceTask = await this.getTask(id); const now = new Date().toISOString(); return this.createTaskWithDistributedReservation({ description: sourceTask.description }, { createTaskWithId: async (newId) => { const newTask: Task = { id: newId, lineageId: generateTaskLineageId(), title: sourceTask.title, description: `${sourceTask.description}\n\n(Duplicated from ${id})`, priority: normalizeTaskPriority(sourceTask.priority), column: "triage", modelPresetId: sourceTask.modelPresetId, sourceType: "task_duplicate", sourceParentTaskId: id, dependencies: [], steps: [], currentStep: 0, log: [{ timestamp: now, action: `Duplicated from ${id}` }], columnMovedAt: now, createdAt: now, updatedAt: now, baseBranch: sourceTask.baseBranch, }; this.assertTaskIdAvailable(newId); const newDir = this.taskDir(newId); await this.atomicCreateTaskJson(newDir, newTask, "duplicateTask"); const validation = validateFileScopeInPromptContent(sourceTask.prompt); if (validation.invalid.length > 0) { this.deleteTaskById(newId); const { rm } = await import("node:fs/promises"); if (existsSync(newDir)) { await rm(newDir, { recursive: true, force: true }); } throw new InvalidFileScopeError(newId, validation.invalid); } await mkdir(newDir, { recursive: true }); await writeFile(join(newDir, "PROMPT.md"), sourceTask.prompt); if (this.isWatching) this.taskCache.set(newId, { ...newTask }); this.emit("task:created", newTask); return newTask; }, }); } /** * Create a refinement task from a completed or in-review task. * The new task is created in triage with a dependency on the original task. * Validates the original is in 'done' or 'in-review' column. */ async refineTask(id: string, feedback: string): Promise { const sourceTask = await this.getTask(id); if (sourceTask.column !== "done" && sourceTask.column !== "in-review") { throw new Error( `Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`, ); } if (!feedback?.trim()) { throw new Error("Feedback is required and cannot be empty"); } const now = new Date().toISOString(); let sourceLabel: string; if (sourceTask.title?.trim()) { sourceLabel = sourceTask.title.trim(); } else { const firstLine = sourceTask.description .split("\n") .map((line: string) => line.trim()) .find((line: string) => line.length > 0); sourceLabel = firstLine ? firstLine.replace(/\s+/g, " ") : sourceTask.id; } return this.createTaskWithDistributedReservation({ description: feedback.trim() }, { createTaskWithId: async (newId) => { const newTask: Task = { id: newId, lineageId: generateTaskLineageId(), title: `Refinement: ${sourceLabel}`, description: `${feedback.trim()}\n\nRefines: ${id}`, priority: normalizeTaskPriority(sourceTask.priority), column: "triage", dependencies: [id], sourceType: "task_refine", sourceParentTaskId: id, steps: [], currentStep: 0, log: [{ timestamp: now, action: `Created as refinement of ${id}` }], columnMovedAt: now, createdAt: now, updatedAt: now, attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined, }; this.assertTaskIdAvailable(newId); const newDir = this.taskDir(newId); await this.atomicCreateTaskJson(newDir, newTask, "refineTask"); const prompt = `# ${newTask.title}\n\n${newTask.description}\n`; // Defensive no-op for refine prompts, which typically have no File Scope section. const validation = validateFileScopeInPromptContent(prompt); if (validation.invalid.length > 0) { this.deleteTaskById(newId); const { rm } = await import("node:fs/promises"); if (existsSync(newDir)) { await rm(newDir, { recursive: true, force: true }); } throw new InvalidFileScopeError(newId, validation.invalid); } await mkdir(newDir, { recursive: true }); await writeFile(join(newDir, "PROMPT.md"), prompt); if (sourceTask.attachments && sourceTask.attachments.length > 0) { const sourceAttachDir = join(this.taskDir(id), "attachments"); const targetAttachDir = join(newDir, "attachments"); await mkdir(targetAttachDir, { recursive: true }); for (const attachment of sourceTask.attachments) { const sourcePath = join(sourceAttachDir, attachment.filename); const targetPath = join(targetAttachDir, attachment.filename); if (existsSync(sourcePath)) { const content = await readFile(sourcePath); await writeFile(targetPath, content); } } } if (this.isWatching) this.taskCache.set(newId, { ...newTask }); this.emit("task:created", newTask); return newTask; }, }); } /** * Read a task and its prompt content. */ async getTask(id: string, options?: { activityLogLimit?: number }): Promise { return this.withTaskLock(id, async () => { const task = this.readTaskFromDb(id, options); if (!task) { const archived = this.archiveDb.get(id); if (!archived) { throw new Error(`Task ${id} not found`); } const archivedTask = this.archiveEntryToTask(archived, false); return { ...archivedTask, prompt: archived.prompt ?? this.generatePromptFromArchiveEntry(archived), }; } task.stalledReview = detectStalledReview(task, { now: Date.now() }); // Sync steps from PROMPT.md if task.steps is empty if (task.steps.length === 0) { task.steps = await this.parseStepsFromPrompt(id); } let prompt = ""; const promptPath = join(this.taskDir(id), "PROMPT.md"); if (existsSync(promptPath)) { prompt = await readFile(promptPath, "utf-8"); } return { ...task, prompt }; }); } async listTasks(options?: { limit?: number; offset?: number; /** When false, exclude tasks in the `archived` column. Default: true (backward compatible). */ includeArchived?: boolean; /** When true, omit heavy fields (log, comments, steps, workflowStepResults, steeringComments) * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */ column?: Column; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; }): Promise { const includeArchived = options?.includeArchived ?? true; const slim = options?.slim ?? false; const columnFilter = options?.column; const startupMemoEnabled = options?.startupMemo ?? (!this.isWatching && slim); if (startupMemoEnabled && slim && options?.limit === undefined && options?.offset === undefined) { const memoKey = `${includeArchived ? "all" : "active"}:${columnFilter ?? "*"}`; const now = Date.now(); const cached = this.startupSlimListMemo.get(memoKey); if (cached && cached.expiresAt > now) { const memoTasks = await cached.promise; return JSON.parse(JSON.stringify(memoTasks)) as Task[]; } const fetchPromise = this.listTasks({ ...options, startupMemo: false }); this.startupSlimListMemo.set(memoKey, { expiresAt: now + TaskStore.STARTUP_SLIM_LIST_MEMO_TTL_MS, promise: fetchPromise, }); try { const memoTasks = await fetchPromise; return JSON.parse(JSON.stringify(memoTasks)) as Task[]; } catch (error) { this.startupSlimListMemo.delete(memoKey); throw error; } } // Slim mode drops ONLY the agent log column. On busy boards `log` accounts // for ~99% of the row payload (60+ MB across 1200 tasks); every other JSON // column combined is under 500 KB and is needed by the board UI: // - `steps` → step progress badge on TaskCard // - `comments` → comment count badge on TaskCard // - `workflowStepResults` → workflow status indicators // - `steeringComments` → steering badge // Use `getTask(id)` to load the full row (including `log`) for the // TaskDetailModal's Activity tab and Agent Log subview. const selectClause = this.getTaskSelectClause(slim); const whereParts: string[] = []; const params: string[] = []; if (columnFilter) { whereParts.push(`"column" = ?`); params.push(columnFilter); } else if (!includeArchived) { whereParts.push(`"column" != 'archived'`); } const whereClause = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : ""; const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`; const rows = this.db.prepare(sql).all(...params); const now = Date.now(); const settings = await this.getSettingsFast(); const staleThresholds: TaskAgeStalenessThresholds = { inProgressWarningMs: settings.staleInProgressWarningMs, inProgressCriticalMs: settings.staleInProgressCriticalMs, inReviewWarningMs: settings.staleInReviewWarningMs, inReviewCriticalMs: settings.staleInReviewCriticalMs, }; let disableAgeStalenessHydration = false; const activeTasks = await Promise.all((rows as unknown as TaskRow[]).map(async (row) => { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now }); if (!disableAgeStalenessHydration) { try { task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds }); } catch (error) { if (error instanceof RangeError) { disableAgeStalenessHydration = true; storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this listTasks pass", { error: error.message, }); } else { throw error; } } } task.stalledReview = detectStalledReview(task, { now }); // Slim path: aggregate the timed-execution total server-side, then // strip the heavy log payload from the wire response. Without this // the board card has no way to display the same total-execution // figure that the task detail panel shows. if (slim) { task.timedExecutionMs = this.computeTimedExecutionMs(task.log); task.log = []; } if (!slim || task.steps.length > 0) { return task; } const steps = await this.parseStepsFromPrompt(task.id); return steps.length > 0 ? { ...task, steps } : task; })); const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") ? this.archiveDb.list().map((entry) => this.archiveEntryToTask(entry, slim)) : []; const tasks = [...activeTasks, ...archivedTasks]; // Sort by createdAt, then by numeric ID suffix for tie-breaking const sorted = tasks.sort((a, b) => { const cmp = a.createdAt.localeCompare(b.createdAt); if (cmp !== 0) return cmp; const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0; const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0; return aNum - bNum; }); const offset = Math.max(0, options?.offset ?? 0); const limit = options?.limit; if (limit === undefined) { return sorted.slice(offset); } return sorted.slice(offset, offset + Math.max(0, limit)); } private clearStartupSlimListMemo(): void { this.startupSlimListMemo.clear(); } /** * List slim task rows with `updatedAt` strictly greater than the cursor. * * Uses strict `>` cursor semantics (rows where `updatedAt === since` are excluded), * returns rows ordered by `updatedAt ASC`, defaults limit to 50, and caps at 200. * Archived tasks are excluded by default unless `opts.includeArchived` is true. * * Callers should re-invoke this method with the last returned task's `updatedAt` * as the next `since` cursor. */ async listTasksModifiedSince( since: string, limit?: number, opts?: { includeArchived?: boolean }, ): Promise<{ tasks: Task[]; hasMore: boolean }> { if (Number.isNaN(Date.parse(since))) { throw new TypeError("listTasksModifiedSince: invalid since cursor"); } const defaultLimit = 50; const resolvedLimit = typeof limit !== "number" || !Number.isFinite(limit) ? defaultLimit : Math.max(1, Math.min(200, Math.floor(limit))); const includeArchived = opts?.includeArchived ?? false; const selectClause = this.getTaskSelectClause(true); const rows = includeArchived ? (this.db.prepare( `SELECT ${selectClause} FROM tasks WHERE updatedAt > ? ORDER BY updatedAt ASC LIMIT ?`, ).all(since, resolvedLimit + 1) as TaskRow[]) : (this.db.prepare( `SELECT ${selectClause} FROM tasks WHERE updatedAt > ? AND "column" != 'archived' ORDER BY updatedAt ASC LIMIT ?`, ).all(since, resolvedLimit + 1) as TaskRow[]); const hasMore = rows.length > resolvedLimit; const now = Date.now(); const settings = await this.getSettingsFast(); const staleThresholds: TaskAgeStalenessThresholds = { inProgressWarningMs: settings.staleInProgressWarningMs, inProgressCriticalMs: settings.staleInProgressCriticalMs, inReviewWarningMs: settings.staleInReviewWarningMs, inReviewCriticalMs: settings.staleInReviewCriticalMs, }; let disableAgeStalenessHydration = false; const tasks = rows.slice(0, resolvedLimit).map((row) => { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now }); if (!disableAgeStalenessHydration) { try { task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds }); } catch (error) { if (error instanceof RangeError) { disableAgeStalenessHydration = true; storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this modified-since pass", { error: error.message, }); } else { throw error; } } } task.timedExecutionMs = this.computeTimedExecutionMs(task.log); task.stalledReview = detectStalledReview(task, { now }); task.log = []; return task; }); return { tasks, hasMore }; } /** * Returns the ID of a task currently in an active merge status ("merging" or * "merging-pr"), optionally excluding a specific task ID. * * This is a lightweight database-level check used as a cross-process guard: * multiple engine processes share the same SQLite database, but each has its * own in-memory merge queue. Without this check, two processes can start * merging different tasks simultaneously. */ getActiveMergingTask(excludeTaskId?: string): string | undefined { const sql = excludeTaskId ? `SELECT id FROM tasks WHERE status IN ('merging', 'merging-pr') AND id != ? LIMIT 1` : `SELECT id FROM tasks WHERE status IN ('merging', 'merging-pr') LIMIT 1`; const params = excludeTaskId ? [excludeTaskId] : []; const row = this.db.prepare(sql).get(...params) as { id: string } | undefined; return row?.id; } /** * Search tasks by full-text query across title, ID, description, and comments. * Uses SQLite FTS5 for fast tokenized matching with relevance ranking. * Falls back to listTasks() for empty/whitespace-only queries. * * @param query - The search query string * @param options - Optional limit and offset for pagination */ async searchTasks(query: string, options?: { limit?: number; offset?: number; slim?: boolean; includeArchived?: boolean }): Promise { // Fall back to listTasks for empty/whitespace-only queries const trimmedQuery = query?.trim(); if (!trimmedQuery) { return this.listTasks(options); } // Sanitize query: strip FTS5 operators so both code paths see the same token set const sanitizedTokens = trimmedQuery .split(/\s+/) .filter((token) => token.length > 0) .map((token) => token.replace(/["{}:*^+()]/g, "")) .filter((token) => token.length > 0); if (sanitizedTokens.length === 0) { return this.listTasks(options); } const limit = options?.limit ?? -1; const offset = options?.offset ?? 0; const offsetClause = offset > 0 ? ` OFFSET ${offset}` : ""; const includeArchived = options?.includeArchived ?? true; const slim = options?.slim ?? false; const selectClause = this.getTaskSelectClause(slim, "t"); let rows: TaskRow[]; if (this.db.fts5Available) { // For FTS5 MATCH, quote tokens that contain special characters like hyphens // to prevent them from being interpreted as operators // Append `*` to each token for FTS5 prefix matching so partial input // (e.g., "frob") matches indexed terms like "frobnicator". const ftsQuery = sanitizedTokens .map((token) => { if (/[":(){}*^+-]/.test(token)) { return `"${token.replace(/"/g, '\\"')}"*`; } return `${token}*`; }) .join(" OR "); const whereClause = includeArchived ? "" : ` AND t."column" != 'archived'`; rows = this.db.prepare(` SELECT ${selectClause} FROM tasks t JOIN tasks_fts fts ON t.rowid = fts.rowid WHERE tasks_fts MATCH ? ${whereClause} ORDER BY rank LIMIT ${limit >= 0 ? limit : -1}${offsetClause} `).all(ftsQuery) as unknown as TaskRow[]; } else { // LIKE fallback: any token matching any searchable column counts as a hit. // Tokens are OR'd; per token we OR across id/title/description/comments. // ESCAPE '\\' lets us include user input containing % or _ literally. const searchColumns = ["id", "title", "description", "comments"]; const perTokenClause = `(${searchColumns .map((c) => `t."${c}" LIKE ? ESCAPE '\\'`) .join(" OR ")})`; const whereTokens = sanitizedTokens.map(() => perTokenClause).join(" OR "); const params: string[] = []; for (const token of sanitizedTokens) { const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`; for (let i = 0; i < searchColumns.length; i++) params.push(pattern); } const archivedClause = includeArchived ? "" : ` AND t."column" != 'archived'`; rows = this.db.prepare(` SELECT ${selectClause} FROM tasks t WHERE (${whereTokens})${archivedClause} ORDER BY t.createdAt ASC LIMIT ${limit >= 0 ? limit : -1}${offsetClause} `).all(...params) as unknown as TaskRow[]; } const now = Date.now(); const settings = await this.getSettingsFast(); const staleThresholds: TaskAgeStalenessThresholds = { inProgressWarningMs: settings.staleInProgressWarningMs, inProgressCriticalMs: settings.staleInProgressCriticalMs, inReviewWarningMs: settings.staleInReviewWarningMs, inReviewCriticalMs: settings.staleInReviewCriticalMs, }; let disableAgeStalenessHydration = false; const activeMatches = await Promise.all(rows.map(async (row) => { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now }); if (!disableAgeStalenessHydration) { try { task.ageStaleness = getTaskAgeStalenessSignal(task, { now, thresholds: staleThresholds }); } catch (error) { if (error instanceof RangeError) { disableAgeStalenessHydration = true; storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this searchTasks pass", { error: error.message, }); } else { throw error; } } } // Slim path mirrors `listTasks`: aggregate timed execution server-side // before stripping the heavy log payload from the wire response. if (slim) { task.timedExecutionMs = this.computeTimedExecutionMs(task.log); task.log = []; } if (task.steps.length > 0) { return task; } const steps = await this.parseStepsFromPrompt(task.id); return steps.length > 0 ? { ...task, steps } : task; })); const archiveMatches = includeArchived ? this.archiveDb.search(trimmedQuery, limit >= 0 ? limit : 100).map((entry) => this.archiveEntryToTask(entry, slim)) : []; const matches = [...activeMatches, ...archiveMatches]; return limit >= 0 ? matches.slice(0, limit) : matches; } async getTasksByAssignedAgent( agentId: string, options?: { pausedOnly?: boolean; excludeArchived?: boolean }, ): Promise { const whereClauses = ["assignedAgentId = ?"]; const params: Array = [agentId]; if (options?.pausedOnly) { whereClauses.push("paused = 1"); } if (options?.excludeArchived) { whereClauses.push('"column" != \'archived\''); } const selectClause = this.getTaskSelectClause(false); const rows = this.db.prepare(` SELECT ${selectClause} FROM tasks WHERE ${whereClauses.join(" AND ")} ORDER BY createdAt ASC `).all(...params) as TaskRow[]; return rows.map((row) => this.rowToTask(row)); } async selectNextTaskForAgent( agentId: string, agent?: Pick, ): Promise { const hasExecutorRoleOverride = (task: Task): boolean => task.sourceMetadata?.executorRoleOverride === true; const tasks = await this.listTasks({ slim: true }); if (tasks.length === 0) { return null; } const tasksById = new Map(tasks.map((task) => [task.id, task])); const isCheckoutAware = "checkoutTask" in this && typeof (this as Record).checkoutTask === "function"; const isDoneLike = (task: Task | undefined) => task?.column === "done" || task?.column === "archived"; const sortByOldestColumnMove = (a: Task, b: Task) => { const aSortAt = a.columnMovedAt ?? a.createdAt; const bSortAt = b.columnMovedAt ?? b.createdAt; return aSortAt.localeCompare(bSortAt); }; const assignedTasks = tasks.filter((task) => task.assignedAgentId === agentId); const inProgress = assignedTasks.filter((task) => task.column === "in-progress").sort(sortByOldestColumnMove); if (inProgress.length > 0) { return { task: inProgress[0], priority: "in_progress", reason: "Resuming in-progress task assigned to this agent", }; } const roleCompatibleAssignedTasks = agent ? assignedTasks.filter((task) => { if (task.column === "in-progress" || hasExecutorRoleOverride(task)) { return true; } return canAgentTakeImplementationTaskForExplicitRouting(agent, task); }) : assignedTasks; const todoCandidates = roleCompatibleAssignedTasks.filter((task) => task.column === "todo" && task.paused !== true); const readyTodo = todoCandidates .filter((task) => { if (isCheckoutAware && task.checkedOutBy && task.checkedOutBy !== agentId) { return false; } return this.areAllDependenciesDone(task.dependencies, tasksById); }) .sort(sortByOldestColumnMove); if (readyTodo.length > 0) { return { task: readyTodo[0], priority: "todo", reason: "Selecting oldest ready todo task assigned to this agent", }; } const actionableBlocked = todoCandidates .filter((task) => { if (isCheckoutAware && task.checkedOutBy && task.checkedOutBy !== agentId) { return false; } if (this.areAllDependenciesDone(task.dependencies, tasksById)) { return false; } return task.dependencies.some((dependencyId) => isDoneLike(tasksById.get(dependencyId))); }) .sort(sortByOldestColumnMove); if (actionableBlocked.length > 0) { return { task: actionableBlocked[0], priority: "blocked", reason: "Selecting partially actionable blocked task assigned to this agent", }; } return null; } private areAllDependenciesDone(dependencies: string[], tasksById: Map): boolean { return dependencies.every((dependencyId) => { const dependency = tasksById.get(dependencyId); return dependency?.column === "done" || dependency?.column === "archived"; }); } async moveTask( id: string, toColumn: Column, options?: { /** * Mark this transition as an internal bounce/pause hop rather than a * user-initiated reset. On in-progress/done/in-review → todo/triage, * skip the destructive cleanup that would otherwise discard resume * state: leave step statuses intact (no resetAllStepsToPending), do * not rewrite PROMPT.md checkboxes, and keep `worktree` + * `executionStartedAt` so the resumed run reattaches to the same * checkout and preserves wall-clock execution time. `status`, * `error`, and `blockedBy` are still cleared because those are * per-run failure state that the next run will rebuild. * * Used by the workflow-rerun bounce, the pause→todo paths, and * other executor-internal requeues. NOT used by user-initiated * "move back to todo" actions, which still want a clean slate. */ preserveResumeState?: boolean; /** * Preserve step progress (step statuses + currentStep) when reopening * to todo/triage, while still clearing per-run execution state * (worktree and wall-clock timing fields). */ preserveProgress?: boolean; /** * Skip the default "release worktree on requeue" behavior. Used by * internal bounce paths (e.g. workflow-rerun) that immediately * promote the task back to in-progress on the same checkout, where * publishing an interim `worktree=null` state to listeners would be * misleading. Has no effect on transitions that don't otherwise * clear the worktree. */ preserveWorktree?: boolean; /** * When true, do not clear task.status/task.error/task.pausedReason on * reopen-to-todo/triage transitions. Required so recovery handlers that * bounce through todo can keep sticky failed state context. */ preserveStatus?: boolean; /** * When transitioning to in-progress on a task that has no worktree * assigned, invoke this allocator to pick a path. The store calls * the allocator with a fresh `reservedNames` set (built from every * other task's current `worktree`) inside a cross-task allocation * lock, so two concurrent moves cannot pick the same name. The * allocator should return an absolute path or `null` to skip * allocation. Provided by callers (the manual-move route, the * scheduler) so the store stays free of worktree-naming policy. */ allocateWorktree?: (reservedNames: Set) => string | null; }, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); let task: Task; try { task = await this.readTaskJson(dir); } catch (error) { const archived = this.archiveDb.get(id); if (!archived) { throw error; } task = this.archiveEntryToTask(archived, false); } if (task.column === "done" && toColumn === "done") { if (this.clearDoneTransientFields(task)) { task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); } return task; } const validTargets = VALID_TRANSITIONS[task.column]; if (!validTargets.includes(toColumn)) { throw new Error( `Invalid transition: '${task.column}' → '${toColumn}'. ` + `Valid targets: ${validTargets.join(", ") || "none"}`, ); } const fromColumn = task.column; if (fromColumn === "in-review" && toColumn === "done") { const mergeBlocker = getTaskMergeBlocker(task); if (mergeBlocker) { throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`); } } task.column = toColumn; task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; // Wall-clock end-to-end runtime: set on first transition into in-progress // and first transition into done. Never overwritten — see retry-clear // logic below for the path that resets these for a fresh run. if (toColumn === "in-progress" && !task.executionStartedAt) { task.executionStartedAt = task.columnMovedAt; } if (toColumn === "done" && !task.executionCompletedAt) { task.executionCompletedAt = task.columnMovedAt; } // Clear transient fields when moving to done (matches moveToDone behavior) if (toColumn === "done") { this.clearDoneTransientFields(task); } // Clear transient fields when reopening/resetting a task into todo/triage. // This ensures failed tasks don't show failed status after being moved for retry. // Note: recovery metadata (recoveryRetryCount, nextRecoveryAt) is intentionally // preserved here — the recovery-policy module manages those fields. They are // only cleared on terminal transitions (in-review, done, archived). const isReopenToTodoOrTriage = (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") && (toColumn === "todo" || toColumn === "triage"); if (isReopenToTodoOrTriage) { if (!options?.preserveStatus) { task.status = undefined; task.error = undefined; task.pausedReason = undefined; } task.blockedBy = undefined; task.paused = undefined; task.pausedByAgentId = undefined; const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); const preserveStepProgress = options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); // Default: release the on-disk worktree directory on requeue. The // checkout may have been removed, may now collide with another // task's allocation, or may simply be abandoned by the bounce. // `task.branch` is intentionally left intact so the next run can // reattach to the same line of work — the executor's worktree // creation path falls back to `git worktree add ` // when the branch already exists, so any committed progress is // preserved even though a fresh directory is allocated. // // Opt-out: internal bounces that immediately re-promote the task // to in-progress on the same checkout (e.g. workflow-rerun) pass // `preserveWorktree: true` so listeners never observe an interim // `worktree=null` state. if (!options?.preserveWorktree) { task.worktree = undefined; } if (!options?.preserveResumeState) { // Reset wall-clock runtime so the next run gets a fresh timer. task.executionStartedAt = undefined; task.executionCompletedAt = undefined; } else { // executionCompletedAt is never set on an in-progress task; clear // it defensively in case we are bouncing from done/in-review. task.executionCompletedAt = undefined; } if (!preserveStepProgress) { this.resetAllStepsToPending(task); await this.resetPromptCheckboxes(dir); } } // Clear recovery metadata when task reaches in-review (successful completion) if (toColumn === "in-review") { task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; } // Clear workflow step results when reopening from review/completed states. // This ensures fresh workflow step runs on retry if ( (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) ) { task.workflowStepResults = undefined; } // Full reset when sending an in-review task back to todo or triage // (respec): discard prior branch/summary/recovery state so the next run // starts from scratch. if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { task.branch = undefined; task.executionStartBranch = undefined; task.baseCommitSha = undefined; task.summary = undefined; task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; } // Atomic worktree allocation on transition to in-progress. // Wrapped in withWorktreeAllocationLock so the read-tasks → pick-name // sequence cannot interleave with another concurrent moveTask. The // caller supplies the naming policy via the `allocateWorktree` // callback; the store builds `reservedNames` here so the snapshot // is fresh under the global lock. if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) { const allocator = options.allocateWorktree; const allocated = await this.withWorktreeAllocationLock(async () => { const others = await this.listTasks({ slim: true, includeArchived: false }); const reservedNames = new Set(); for (const other of others) { if (other.id === id || !other.worktree) continue; const name = other.worktree.split("/").filter(Boolean).pop(); if (name) reservedNames.add(name); } return allocator(reservedNames); }); if (allocated) { task.worktree = allocated; } } await this.atomicWriteTaskJson(dir, task); if (toColumn === "done") { this.clearLinkedAgentTaskIds(id, task.updatedAt); } // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:moved", { task, from: fromColumn, to: toColumn }); return task; }); } private resetAllStepsToPending(task: Task): void { if (task.steps.length === 0) { return; } for (const step of task.steps) { step.status = "pending"; } task.currentStep = 0; } private async resetPromptCheckboxes(dir: string): Promise { const promptPath = join(dir, "PROMPT.md"); if (!existsSync(promptPath)) { return; } const content = await readFile(promptPath, "utf-8"); const resetContent = content.replace(/^- \[x\]/gm, "- [ ]"); if (resetContent !== content) { await writeFile(promptPath, resetContent, "utf-8"); } } async updateTask( id: string, updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { return this.withTaskLock(id, async () => { // Validate that task doesn't depend on itself if (updates.dependencies?.includes(id)) { throw new Error(`Task ${id} cannot depend on itself`); } const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Capture title/description before mutation so the PROMPT.md stub // detector below can compare against the exact wrapper bytes that the // pre-edit task would have produced. This is what makes detection // robust to descriptions that contain `##` headings or `**Created:**` // text (e.g. imported GitHub issue bodies) — we never inspect the // description content, only the wrapper shape. const preUpdateTitle = task.title; const preUpdateDescription = task.description; if (updates.nodeId !== undefined) { const validation = validateNodeOverrideChange(task, updates.nodeId ?? null); if (!validation.allowed) { throw new Error(validation.message); } } // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } if (updates.title !== undefined) task.title = updates.title; if (updates.description !== undefined) task.description = updates.description; if (updates.priority === null) { task.priority = normalizeTaskPriority(undefined); } else if (updates.priority !== undefined) { task.priority = normalizeTaskPriority(updates.priority); } if (updates.worktree === null) { task.worktree = undefined; } else if (updates.worktree !== undefined) { task.worktree = updates.worktree; } // Detect new dependencies being added to a todo task → auto-move to triage let movedToTriage = false; if (updates.dependencies !== undefined) { const oldDeps = new Set(task.dependencies); const hasNewDeps = updates.dependencies.some((d) => !oldDeps.has(d)); task.dependencies = updates.dependencies; if (hasNewDeps && task.column === "todo") { task.column = "triage"; task.status = undefined; task.columnMovedAt = new Date().toISOString(); const depLogEntry: TaskLogEntry = { timestamp: new Date().toISOString(), action: "Moved to triage for re-specification — new dependency added", }; if (runContext) { depLogEntry.runContext = runContext; } task.log.push(depLogEntry); movedToTriage = true; } } if (updates.steps !== undefined) task.steps = updates.steps; if (updates.currentStep !== undefined) task.currentStep = updates.currentStep; if (updates.status === null) { task.status = undefined; } else if (updates.status !== undefined) { task.status = updates.status; } if (updates.blockedBy === null) { task.blockedBy = undefined; } else if (updates.blockedBy !== undefined) { task.blockedBy = updates.blockedBy; } const previousAssignedAgentId = task.assignedAgentId; if (updates.assignedAgentId === null) { task.assignedAgentId = undefined; } else if (updates.assignedAgentId !== undefined) { task.assignedAgentId = updates.assignedAgentId; } // If the agent that paused this task is being unassigned (or replaced), // auto-unpause: the pause was tied to that agent's lifecycle, and now // there's no longer a relationship that justifies keeping the task paused. const assignmentChanged = updates.assignedAgentId !== undefined && task.assignedAgentId !== previousAssignedAgentId; if ( assignmentChanged && task.paused && task.pausedByAgentId && task.pausedByAgentId === previousAssignedAgentId ) { task.paused = undefined; task.pausedByAgentId = undefined; if (task.column === "in-progress" || task.column === "in-review") { if (task.status === "paused") { task.status = undefined; } } task.log.push({ timestamp: new Date().toISOString(), action: `Task unpaused (agent ${previousAssignedAgentId} unassigned)`, ...(runContext ? { runContext } : {}), }); } if (assignmentChanged) { this.syncAgentTaskLinkOnReassignment(id, previousAssignedAgentId, task.assignedAgentId); if (task.checkedOutBy === previousAssignedAgentId) { task.checkedOutBy = undefined; task.checkedOutAt = undefined; } task.log.push({ timestamp: new Date().toISOString(), action: `Agent task link synced: ${previousAssignedAgentId ?? "none"} → ${task.assignedAgentId ?? "none"}`, ...(runContext ? { runContext } : {}), }); } if (updates.pausedByAgentId === null) { task.pausedByAgentId = undefined; } else if (updates.pausedByAgentId !== undefined) { task.pausedByAgentId = updates.pausedByAgentId; } if (updates.pausedReason === null) { task.pausedReason = undefined; } else if (updates.pausedReason !== undefined) { task.pausedReason = updates.pausedReason; } if (updates.tokenBudgetSoftAlertedAt === null) { task.tokenBudgetSoftAlertedAt = undefined; } else if (updates.tokenBudgetSoftAlertedAt !== undefined) { task.tokenBudgetSoftAlertedAt = updates.tokenBudgetSoftAlertedAt; } if (updates.tokenBudgetHardAlertedAt === null) { task.tokenBudgetHardAlertedAt = undefined; } else if (updates.tokenBudgetHardAlertedAt !== undefined) { task.tokenBudgetHardAlertedAt = updates.tokenBudgetHardAlertedAt; } if (updates.tokenBudgetOverride === null) { task.tokenBudgetOverride = undefined; } else if (updates.tokenBudgetOverride !== undefined) { task.tokenBudgetOverride = updates.tokenBudgetOverride; } if (updates.dispatchStormCount === null) { task.dispatchStormCount = undefined; } else if (updates.dispatchStormCount !== undefined) { task.dispatchStormCount = updates.dispatchStormCount; } if (updates.lastDispatchAt === null) { task.lastDispatchAt = undefined; } else if (updates.lastDispatchAt !== undefined) { task.lastDispatchAt = updates.lastDispatchAt; } if (updates.assigneeUserId === null) { task.assigneeUserId = undefined; } else if (updates.assigneeUserId !== undefined) { task.assigneeUserId = updates.assigneeUserId; } if (updates.scopeOverride === null) { task.scopeOverride = undefined; } else if (updates.scopeOverride !== undefined) { task.scopeOverride = updates.scopeOverride || undefined; } if (updates.scopeOverrideReason === null) { task.scopeOverrideReason = undefined; } else if (updates.scopeOverrideReason !== undefined) { task.scopeOverrideReason = updates.scopeOverrideReason; } if (updates.nodeId === null) { task.nodeId = undefined; } else if (updates.nodeId !== undefined) { task.nodeId = updates.nodeId; } if (updates.effectiveNodeId === null) { task.effectiveNodeId = undefined; } else if (updates.effectiveNodeId !== undefined) { task.effectiveNodeId = updates.effectiveNodeId; } if (updates.effectiveNodeSource === null) { task.effectiveNodeSource = undefined; } else if (updates.effectiveNodeSource !== undefined) { task.effectiveNodeSource = updates.effectiveNodeSource as Task["effectiveNodeSource"]; } if (updates.checkedOutBy === null) { task.checkedOutBy = undefined; task.checkedOutAt = undefined; task.checkoutNodeId = undefined; task.checkoutRunId = undefined; task.checkoutLeaseRenewedAt = undefined; } else if (updates.checkedOutBy !== undefined) { task.checkedOutBy = updates.checkedOutBy; task.checkedOutAt = updates.checkedOutAt ?? task.checkedOutAt ?? new Date().toISOString(); task.checkoutNodeId = updates.checkoutNodeId ?? task.checkoutNodeId; task.checkoutRunId = updates.checkoutRunId ?? task.checkoutRunId; task.checkoutLeaseRenewedAt = updates.checkoutLeaseRenewedAt ?? task.checkoutLeaseRenewedAt ?? task.checkedOutAt; } if (updates.checkoutNodeId === null) { task.checkoutNodeId = undefined; } else if (updates.checkoutNodeId !== undefined && updates.checkedOutBy === undefined) { task.checkoutNodeId = updates.checkoutNodeId; } if (updates.checkoutRunId === null) { task.checkoutRunId = undefined; } else if (updates.checkoutRunId !== undefined && updates.checkedOutBy === undefined) { task.checkoutRunId = updates.checkoutRunId; } if (updates.checkoutLeaseRenewedAt === null) { task.checkoutLeaseRenewedAt = undefined; } else if (updates.checkoutLeaseRenewedAt !== undefined && updates.checkedOutBy === undefined) { task.checkoutLeaseRenewedAt = updates.checkoutLeaseRenewedAt; } if (updates.checkoutLeaseEpoch === null) { task.checkoutLeaseEpoch = undefined; } else if (updates.checkoutLeaseEpoch !== undefined) { task.checkoutLeaseEpoch = updates.checkoutLeaseEpoch; } if (updates.paused !== undefined) task.paused = updates.paused || undefined; if (updates.baseBranch === null) { task.baseBranch = undefined; } else if (updates.baseBranch !== undefined) { task.baseBranch = updates.baseBranch; } if (updates.branch === null) { task.branch = undefined; } else if (updates.branch !== undefined) { task.branch = updates.branch; } if (updates.executionStartBranch === null) { task.executionStartBranch = undefined; } else if (updates.executionStartBranch !== undefined) { task.executionStartBranch = updates.executionStartBranch; } if (updates.baseCommitSha === null) { task.baseCommitSha = undefined; } else if (updates.baseCommitSha !== undefined) { task.baseCommitSha = updates.baseCommitSha; } if (updates.size !== undefined) task.size = updates.size; if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel; if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries; if (updates.workflowStepRetries !== undefined) task.workflowStepRetries = updates.workflowStepRetries; if (updates.stuckKillCount === null) { task.stuckKillCount = undefined; } else if (updates.stuckKillCount !== undefined) { task.stuckKillCount = updates.stuckKillCount; } if (updates.postReviewFixCount === null) { task.postReviewFixCount = undefined; } else if (updates.postReviewFixCount !== undefined) { task.postReviewFixCount = updates.postReviewFixCount; } if (updates.recoveryRetryCount === null) { task.recoveryRetryCount = undefined; } else if (updates.recoveryRetryCount !== undefined) { task.recoveryRetryCount = updates.recoveryRetryCount; } if (updates.taskDoneRetryCount === null) { task.taskDoneRetryCount = undefined; } else if (updates.taskDoneRetryCount !== undefined) { task.taskDoneRetryCount = updates.taskDoneRetryCount; } if (updates.verificationFailureCount === null) { task.verificationFailureCount = undefined; } else if (updates.verificationFailureCount !== undefined) { task.verificationFailureCount = updates.verificationFailureCount; } if (updates.mergeConflictBounceCount === null) { task.mergeConflictBounceCount = undefined; } else if (updates.mergeConflictBounceCount !== undefined) { task.mergeConflictBounceCount = updates.mergeConflictBounceCount; } if (updates.mergeAuditBounceCount === null) { task.mergeAuditBounceCount = undefined; } else if (updates.mergeAuditBounceCount !== undefined) { task.mergeAuditBounceCount = updates.mergeAuditBounceCount; } if (updates.nextRecoveryAt === null) { task.nextRecoveryAt = undefined; } else if (updates.nextRecoveryAt !== undefined) { task.nextRecoveryAt = updates.nextRecoveryAt; } if (updates.enabledWorkflowSteps !== undefined) { task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(updates.enabledWorkflowSteps); } if (updates.modelProvider === null) { task.modelProvider = undefined; } else if (updates.modelProvider !== undefined) { task.modelProvider = updates.modelProvider; } if (updates.modelId === null) { task.modelId = undefined; } else if (updates.modelId !== undefined) { task.modelId = updates.modelId; } if (updates.validatorModelProvider === null) { task.validatorModelProvider = undefined; } else if (updates.validatorModelProvider !== undefined) { task.validatorModelProvider = updates.validatorModelProvider; } if (updates.validatorModelId === null) { task.validatorModelId = undefined; } else if (updates.validatorModelId !== undefined) { task.validatorModelId = updates.validatorModelId; } if (updates.planningModelProvider === null) { task.planningModelProvider = undefined; } else if (updates.planningModelProvider !== undefined) { task.planningModelProvider = updates.planningModelProvider; } if (updates.planningModelId === null) { task.planningModelId = undefined; } else if (updates.planningModelId !== undefined) { task.planningModelId = updates.planningModelId; } if (updates.thinkingLevel === null) { task.thinkingLevel = undefined; } else if (updates.thinkingLevel !== undefined) { task.thinkingLevel = updates.thinkingLevel as import("./types.js").ThinkingLevel; } if (updates.executionMode === null) { task.executionMode = undefined; } else if (updates.executionMode !== undefined) { task.executionMode = updates.executionMode as import("./types.js").ExecutionMode; } if (updates.error === null) { task.error = undefined; } else if (updates.error !== undefined) { task.error = updates.error; } if (updates.summary === null) { task.summary = undefined; } else if (updates.summary !== undefined) { task.summary = updates.summary; } if (updates.sessionFile === null) { task.sessionFile = undefined; } else if (updates.sessionFile !== undefined) { task.sessionFile = updates.sessionFile; } if (updates.executionStartedAt === null) { task.executionStartedAt = undefined; } else if (updates.executionStartedAt !== undefined) { task.executionStartedAt = updates.executionStartedAt; } if (updates.executionCompletedAt === null) { task.executionCompletedAt = undefined; } else if (updates.executionCompletedAt !== undefined) { task.executionCompletedAt = updates.executionCompletedAt; } if (updates.review === null) { task.review = undefined; } else if (updates.review !== undefined) { task.review = updates.review; } if (updates.reviewState === null) { task.reviewState = undefined; } else if (updates.reviewState !== undefined) { task.reviewState = normalizeTaskReviewState(updates.reviewState); } if (updates.workflowStepResults === null) { task.workflowStepResults = undefined; } else if (updates.workflowStepResults !== undefined) { task.workflowStepResults = updates.workflowStepResults; } if (updates.mergeDetails === null) { task.mergeDetails = undefined; } else if (updates.mergeDetails !== undefined) { task.mergeDetails = updates.mergeDetails; } if (updates.sourceIssue === null) { task.sourceIssue = undefined; } else if (updates.sourceIssue !== undefined) { task.sourceIssue = updates.sourceIssue; } if (updates.githubTracking === null) { task.githubTracking = undefined; } else if (updates.githubTracking !== undefined) { task.githubTracking = updates.githubTracking; } if (updates.tokenUsage === null) { task.tokenUsage = undefined; } else if (updates.tokenUsage !== undefined) { task.tokenUsage = updates.tokenUsage; } if (updates.modifiedFiles === null) { task.modifiedFiles = undefined; } else if (updates.modifiedFiles !== undefined) { task.modifiedFiles = updates.modifiedFiles; } if (updates.missionId === null) { task.missionId = undefined; } else if (updates.missionId !== undefined) { task.missionId = updates.missionId; } if (updates.sliceId === null) { task.sliceId = undefined; } else if (updates.sliceId !== undefined) { task.sliceId = updates.sliceId; } task.updatedAt = new Date().toISOString(); // When runContext is provided, record audit event atomically with task mutation if (runContext) { await this.atomicWriteTaskJsonWithAudit(dir, task, { taskId: task.id, agentId: runContext.agentId, runId: runContext.runId, domain: "database", mutationType: "task:update", target: task.id, metadata: { updatedFields: Object.keys(updates).filter((k) => (updates as Record)[k] !== undefined) }, }); } else { await this.atomicWriteTaskJson(dir, task); } // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); if (updates.prompt !== undefined) { const validation = validateFileScopeInPromptContent(updates.prompt); if (validation.invalid.length > 0) { throw new InvalidFileScopeError(id, validation.invalid); } await mkdir(dir, { recursive: true }); await writeFile(join(dir, "PROMPT.md"), updates.prompt); } // Sync PROMPT.md when title or description changes (but not when explicit // prompt update — that already wrote the new content above). // // Two distinct cases: // // (a) Bootstrap stub — the auto-generated `# heading\n\n\n` block // `createTask` writes. Rewrite the whole file from the new title + // description so the human-visible stub stays in sync. // // (b) Real specification (any `##` section header, or the `**Created:**` // / `**Size:**` metadata the triage prompt format requires). Do NOT // rebuild the file from a section whitelist — earlier regressions // either clobbered the spec entirely (FN-3056 + the previous // `regeneratePrompt` path while column='triage') or silently dropped // `## Review Level` / `## Frontend UX Criteria` and other custom // sections (the same regen call on column!='triage'), which left the // executor with reset review levels and missing UX guidance. Instead // just splice the leading `#` heading line so the displayed title // stays in sync with task.json; the body is preserved verbatim. // // task.json remains the canonical source for title/description fields. // PROMPT.md is only ever fully rewritten via explicit `updates.prompt`. if (updates.prompt === undefined && (updates.title !== undefined || updates.description !== undefined)) { const promptPath = join(dir, "PROMPT.md"); if (existsSync(promptPath)) { const existingPrompt = await readFile(promptPath, "utf-8"); if (isBootstrapPromptStub(existingPrompt, task.id, preUpdateTitle, preUpdateDescription)) { const newPrompt = buildBootstrapPrompt(task.id, task.title, task.description); await writeFile(promptPath, newPrompt); } else { // Real spec — surgical edits only. Each section we propagate to is // edited in place; everything else (Review Level, Frontend UX // Criteria, custom sections from triage) is preserved verbatim. let next = existingPrompt; if (updates.title !== undefined) { // Match the existing heading style: triage emits // `# Task: {id} - {title}`; createTask uses `# {id}: {title}`. const triageStyle = /^#\s+Task:\s+[A-Z]+-\d+\s+-\s+/m.test(existingPrompt); const heading = triageStyle ? (task.title ? `Task: ${task.id} - ${task.title}` : `Task: ${task.id}`) : (task.title ? `${task.id}: ${task.title}` : task.id); next = rewriteHeadingLine(next, heading); } if (updates.description !== undefined) { next = rewriteMissionSection(next, task.description); } if (next !== existingPrompt) { await writeFile(promptPath, next); } } } } if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column }); } this.emit("task:updated", task); return task; }); } /** * Pause or unpause a task. Paused tasks are excluded from all automated * agent and scheduler interaction. Logs the action and emits `task:updated`. */ async pauseTask( id: string, paused: boolean, runContext?: RunMutationContext, agentOptions?: { pausedByAgentId?: string }, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } const previousPausedByAgentId = task.pausedByAgentId; task.paused = paused || undefined; if (paused && agentOptions?.pausedByAgentId) { task.pausedByAgentId = agentOptions.pausedByAgentId; } if (!paused) { task.pausedByAgentId = undefined; } // When pausing an in-progress/in-review task, set status so the UI can show the state. // When unpausing, clear the "paused" status. if (task.column === "in-progress" || task.column === "in-review") { task.status = paused ? "paused" : undefined; } const now = new Date().toISOString(); task.updatedAt = now; const logEntry: TaskLogEntry = { timestamp: now, action: paused ? (agentOptions?.pausedByAgentId ? `Task paused (agent ${agentOptions.pausedByAgentId} paused)` : "Task paused") : (previousPausedByAgentId ? `Task unpaused (agent ${previousPausedByAgentId} resumed)` : "Task unpaused"), }; if (runContext) { logEntry.runContext = runContext; } task.log.push(logEntry); // When runContext is provided, record audit event atomically with task mutation if (runContext) { await this.atomicWriteTaskJsonWithAudit(dir, task, { taskId: task.id, agentId: runContext.agentId, runId: runContext.runId, domain: "database", mutationType: paused ? "task:pause" : "task:unpause", target: task.id, }); } else { await this.atomicWriteTaskJson(dir, task); } if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } /** * Update a step's status. Automatically advances currentStep. */ async updateStep( id: string, stepIndex: number, status: import("./types.js").StepStatus, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Auto-initialize steps from PROMPT.md if empty if (task.steps.length === 0) { task.steps = await this.parseStepsFromPrompt(id); } // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } if (stepIndex < 0 || stepIndex >= task.steps.length) { throw new Error( `Step ${stepIndex} out of range (task has ${task.steps.length} steps)`, ); } // Guard against agents (or stale tool calls) regressing completed work // by re-marking a done/skipped step as "in-progress". Overwriting the // step status would silently undo progress, and the currentStep // rewind below would discard the task's place in the plan. const currentStatus = task.steps[stepIndex].status; if ( status === "in-progress" && (currentStatus === "done" || currentStatus === "skipped") ) { const ts = new Date().toISOString(); task.updatedAt = ts; task.log.push({ timestamp: ts, action: `Ignored ${currentStatus}→in-progress regression for step ${stepIndex} (${task.steps[stepIndex].name})`, }); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; } task.steps[stepIndex].status = status; task.updatedAt = new Date().toISOString(); // Advance currentStep to first non-done step if (status === "done") { while ( task.currentStep < task.steps.length && task.steps[task.currentStep].status === "done" ) { task.currentStep++; } } else if (status === "in-progress") { task.currentStep = stepIndex; } // Log it task.log.push({ timestamp: task.updatedAt, action: `Step ${stepIndex} (${task.steps[stepIndex].name}) → ${status}`, }); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } /** * Add a log entry to a task. */ async logEntry(id: string, action: string, outcome?: string, runContext?: RunMutationContext): Promise { return this.withTaskLock(id, async () => { const entry: TaskLogEntry = { timestamp: new Date().toISOString(), action, outcome: truncateTaskLogOutcome(outcome), }; if (runContext) { if (this.isTaskArchived(id)) { throw new Error(`Task ${id} is archived — logging is read-only`); } const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } entry.runContext = runContext; task.log.push(entry); if (task.log.length > TASK_ACTIVITY_LOG_ENTRY_LIMIT) { task.log.splice(0, task.log.length - TASK_ACTIVITY_LOG_ENTRY_LIMIT); } task.updatedAt = new Date().toISOString(); // When runContext is provided, record audit event atomically with task mutation. await this.atomicWriteTaskJsonWithAudit(dir, task, { taskId: task.id, agentId: runContext.agentId, runId: runContext.runId, domain: "database", mutationType: "task:log", target: task.id, metadata: { action, outcome }, }); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; } // Fast path for high-volume log entries: update only the log + updatedAt fields // instead of reading/writing the entire task payload on every append. const row = this.db.prepare('SELECT log, "column" FROM tasks WHERE id = ?').get(id) as | { log: string | null; column: Column } | undefined; if (!row) { if (this.isTaskArchived(id)) { throw new Error(`Task ${id} is archived — logging is read-only`); } throw new Error(`Task ${id} not found`); } if (row.column === "archived") { throw new Error(`Task ${id} is archived — logging is read-only`); } const log = fromJson(row.log) || []; log.push(entry); if (log.length > TASK_ACTIVITY_LOG_ENTRY_LIMIT) { log.splice(0, log.length - TASK_ACTIVITY_LOG_ENTRY_LIMIT); } const updatedAt = new Date().toISOString(); this.db.prepare("UPDATE tasks SET log = ?, updatedAt = ? WHERE id = ?").run(toJson(log), updatedAt, id); this.db.bumpLastModified(); const current = this.readTaskFromDb(id); if (current) { await this.writeTaskJsonFile(this.taskDir(id), current); if (this.isWatching) { this.taskCache.set(id, { ...current }); } this.emit("task:updated", current); return current; } const emittedTask = ({ id, log, updatedAt } as unknown) as Task; this.emit("task:updated", emittedTask); return emittedTask; }); } /** * Get all task log entries correlated with a specific run ID. * Scans all tasks' logs for entries whose runContext.runId matches. */ async getMutationsForRun(runId: string): Promise { const rows = this.db.prepare("SELECT log FROM tasks").all() as Array<{ log: string | null }>; const mutations: TaskLogEntry[] = []; for (const row of rows) { const logEntries = fromJson(row.log) || []; for (const entry of logEntries) { if (entry.runContext?.runId === runId) { mutations.push(entry); } } } // Sort by timestamp ascending return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } // ── Run Audit APIs ─────────────────────────────────────────────────── /** * Convert a database row to a RunAuditEvent object. */ private rowToRunAuditEvent(row: RunAuditEventRow): RunAuditEvent { return { id: row.id, timestamp: row.timestamp, taskId: row.taskId || undefined, agentId: row.agentId, runId: row.runId, domain: row.domain as RunAuditEvent["domain"], mutationType: row.mutationType, target: row.target, metadata: fromJson>(row.metadata), }; } /** * Record a run-audit event. * * Persists a structured audit trail entry correlating a mutation to the * heartbeat run that caused it. Use this to track database mutations, * git operations, and filesystem changes initiated by agent runs. * * @param input - The audit event input (runId, agentId, domain, mutationType, target, optional metadata) * @returns The persisted RunAuditEvent with generated id and timestamp */ recordRunAuditEvent(input: RunAuditEventInput): RunAuditEvent { const id = randomUUID(); const timestamp = input.timestamp ?? new Date().toISOString(); const event: RunAuditEvent = { id, timestamp, taskId: input.taskId, agentId: input.agentId, runId: input.runId, domain: input.domain, mutationType: input.mutationType, target: input.target, metadata: input.metadata, }; this.db.transactionImmediate(() => { this.db.prepare(` INSERT INTO runAuditEvents ( id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( event.id, event.timestamp, event.taskId ?? null, event.agentId, event.runId, event.domain, event.mutationType, event.target, toJsonNullable(event.metadata), ); }); return event; } /** * Query run-audit events with optional filters. * * @param options - Filter options (runId, taskId, startTime, endTime, domain, mutationType, limit) * @returns Array of matching RunAuditEvent records, ordered by timestamp DESC, rowid DESC * * @remarks * Time-range filtering uses **inclusive bounds**: `timestamp >= startTime` and `timestamp <= endTime`. * When no time range is specified, all matching records are returned. * * Query results are ordered by timestamp descending with a stable rowid tiebreaker: * `ORDER BY timestamp DESC, rowid DESC`. This ensures deterministic ordering * when multiple events share the same millisecond timestamp. */ getRunAuditEvents(options: RunAuditEventFilter = {}): RunAuditEvent[] { const conditions: string[] = []; const params: unknown[] = []; if (options.runId) { conditions.push("runId = ?"); params.push(options.runId); } if (options.taskId) { conditions.push("taskId = ?"); params.push(options.taskId); } if (options.agentId) { conditions.push("agentId = ?"); params.push(options.agentId); } if (options.domain) { conditions.push("domain = ?"); params.push(options.domain); } if (options.mutationType) { conditions.push("mutationType = ?"); params.push(options.mutationType); } // Inclusive time range: timestamp >= startTime AND timestamp <= endTime if (options.startTime) { conditions.push("timestamp >= ?"); params.push(options.startTime); } if (options.endTime) { conditions.push("timestamp <= ?"); params.push(options.endTime); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limitClause = options.limit ? `LIMIT ${Math.max(1, options.limit)}` : ""; const orderClause = "ORDER BY timestamp DESC, rowid DESC"; // Cast params to the expected SQLite input type const sqlParams = params as (string | number | null)[]; const rows = this.db.prepare(` SELECT * FROM runAuditEvents ${whereClause} ${orderClause} ${limitClause} `).all(...sqlParams) as unknown as RunAuditEventRow[]; return rows.map((row) => this.rowToRunAuditEvent(row)); } // ── End Run Audit APIs ─────────────────────────────────────────────── /** * Sync steps from PROMPT.md into task.json (called when steps are empty). */ async parseStepsFromPrompt(id: string): Promise { const dir = this.taskDir(id); const promptPath = join(dir, "PROMPT.md"); if (!existsSync(promptPath)) return []; const content = await readFile(promptPath, "utf-8"); const steps: import("./types.js").TaskStep[] = []; const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm; let match; while ((match = stepRegex.exec(content)) !== null) { steps.push({ name: match[1].trim(), status: "pending" }); } return steps; } /** * Parse the `## Dependencies` section from a task's PROMPT.md and extract * task IDs from lines matching `- **Task:** {ID}` (where ID is `[A-Z]+-\d+`). * * Returns an empty array if the section says `- **None**`, has no task * references, or if the section/file doesn't exist. * * @param id - The task ID whose PROMPT.md to parse * @returns Array of dependency task IDs (e.g. `["KB-001", "KB-002"]`) */ async parseDependenciesFromPrompt(id: string): Promise { const dir = this.taskDir(id); const promptPath = join(dir, "PROMPT.md"); if (!existsSync(promptPath)) return []; const content = await readFile(promptPath, "utf-8"); // Find the ## Dependencies section. // We locate the heading then slice to the next heading (or end of file) // to avoid multiline `$` anchor issues with lazy quantifiers. const headingMatch = content.match(/^##\s+Dependencies\s*$/m); if (!headingMatch) return []; const startIdx = headingMatch.index! + headingMatch[0].length; const rest = content.slice(startIdx); const nextHeading = rest.search(/\n##?\s/); const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); const ids: string[] = []; const taskIdRegex = /^-\s+\*\*Task:\*\*\s+([A-Z]+-\d+)/gm; let match; while ((match = taskIdRegex.exec(section)) !== null) { ids.push(match[1]); } return ids; } /** * Parse the `## File Scope` section from a task's PROMPT.md and extract * backtick-quoted file paths. Glob patterns ending in `/*` are stored * as directory prefixes for overlap comparison. */ async parseFileScopeFromPrompt(id: string): Promise { const dir = this.taskDir(id); const promptPath = join(dir, "PROMPT.md"); if (!existsSync(promptPath)) return []; const content = await readFile(promptPath, "utf-8"); const paths = extractFileScopeTokens(content); return paths.filter((path) => isValidFileScopeEntry(path)); } async deleteTask( id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }, ): Promise { return this.withTaskLock(id, async () => { // Flush buffered agent logs inside the lock so no new appends for this // task can sneak in between flush and DELETE. this.flushAgentLogBuffer(); const task = this.readTaskFromDb(id); if (!task) { throw new Error(`Task ${id} not found`); } // Refuse to delete a task that is still referenced as a dependency // by another live task unless the caller explicitly opts into // removing those incoming references as part of this delete. const dependentIds = this.findLiveDependents(id); if (dependentIds.length > 0 && !options?.removeDependencyReferences) { throw new TaskHasDependentsError(id, dependentIds); } // Clean up the task's branch before deleting from DB const cleanedBranches = await this.cleanupBranchForTask(task); if (cleanedBranches.length > 0) { if (!task.log) task.log = []; task.log.push({ timestamp: new Date().toISOString(), action: `Cleaned up branch: ${cleanedBranches.join(", ")}`, }); } const rewrittenDependents = this.rewriteDependentsAndDeleteTask(id, dependentIds); // Remove from cache if watcher is active if (this.isWatching) this.taskCache.delete(id); // Delete directory from disk const dir = this.taskDir(id); if (existsSync(dir)) { const { rm } = await import("node:fs/promises"); await rm(dir, { recursive: true }); } for (const dependentTask of rewrittenDependents) { this.emit("task:updated", dependentTask); } this.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" }); return task; }); } private deleteTaskById(taskId: string): void { this.clearLinkedAgentTaskIds(taskId); this.db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); this.db.bumpLastModified(); } private rewriteDependentsAndDeleteTask(taskId: string, dependentIds: string[]): Task[] { const rewrittenDependents: Task[] = []; this.db.transaction(() => { for (const dependentId of dependentIds) { const dependentTask = this.readTaskFromDb(dependentId); if (!dependentTask) continue; const nextDependencies = dependentTask.dependencies.filter((dependencyId) => dependencyId !== taskId); if (nextDependencies.length === dependentTask.dependencies.length) { continue; } const updatedDependent = { ...dependentTask, dependencies: nextDependencies, updatedAt: new Date().toISOString(), }; this.db.prepare("UPDATE tasks SET dependencies = ?, updatedAt = ? WHERE id = ?").run( toJson(updatedDependent.dependencies), updatedDependent.updatedAt, updatedDependent.id, ); if (this.isWatching) { this.taskCache.set(updatedDependent.id, updatedDependent); } rewrittenDependents.push(updatedDependent); } this.clearLinkedAgentTaskIds(taskId); this.db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); this.db.bumpLastModified(); }); return rewrittenDependents; } /** * Clear `agent.taskId` links that point at a task which has transitioned out * of active work. This keeps heartbeat scheduling aligned with live task * storage and prevents stale task-scoped heartbeat runs. */ private clearLinkedAgentTaskIds(taskId: string, updatedAt: string = new Date().toISOString()): void { const linkedAgents = this.db .prepare("SELECT id FROM agents WHERE taskId = ?") .all(taskId) as Array<{ id: string }>; if (linkedAgents.length === 0) { return; } this.db.prepare(` UPDATE agents SET taskId = NULL, updatedAt = ?, data = CASE WHEN json_valid(data) THEN json_set(json_remove(data, '$.taskId'), '$.updatedAt', ?) ELSE data END WHERE taskId = ? `).run(updatedAt, updatedAt, taskId); } /** * Sync `agents.taskId` when {@link updateTask} reassigns a task. * * Uses direct SQL against the shared `agents` table instead of AgentStore to * avoid a circular dependency while keeping the column and JSON data blob in * lockstep. Clearing the previous agent is race-guarded with `WHERE id = ? * AND taskId = ?` so we do not clobber an agent that already moved on to a * different task. */ private syncAgentTaskLinkOnReassignment( taskId: string, previousAgentId: string | undefined, newAgentId: string | undefined, ): void { const updatedAt = new Date().toISOString(); if (previousAgentId) { this.db.prepare(` UPDATE agents SET taskId = NULL, updatedAt = ?, data = CASE WHEN json_valid(data) THEN json_set(json_remove(data, '$.taskId'), '$.updatedAt', ?) ELSE data END WHERE id = ? AND taskId = ? `).run(updatedAt, updatedAt, previousAgentId, taskId); } if (newAgentId) { this.db.prepare(` UPDATE agents SET taskId = ?, updatedAt = ?, data = CASE WHEN json_valid(data) THEN json_set(data, '$.taskId', ?, '$.updatedAt', ?) ELSE data END WHERE id = ? `).run(taskId, updatedAt, taskId, updatedAt, newAgentId); } } /** * Clean up the git branch associated with a task. * * Branch name resolution: * 1. Use `task.branch` if set * 2. Fall back to `fusion/${taskId.toLowerCase()}` * * Uses force delete (`git branch -D`) since the task is being removed or archived. * Silently skips if neither branch exists (idempotent). * * @returns Array of branch names that were successfully deleted */ private async runGitCommand(command: string, timeoutMs = 10_000) { return runCommandAsync(command, { cwd: this.rootDir, timeoutMs, maxBuffer: 10 * 1024 * 1024, }); } private async cleanupBranchForTask(task: Task): Promise { const branches = new Set(); if (task.branch) { branches.add(task.branch); } branches.add(`fusion/${task.id.toLowerCase()}`); const deleted: string[] = []; for (const branch of branches) { try { assertSafeGitBranchName(branch); } catch { // Skip branches whose names would be unsafe to pass through a shell. // A malformed stored value should not become a command-injection vector. continue; } const verify = await this.runGitCommand(`git rev-parse --verify "${branch}"`); if (verify.exitCode !== 0) { continue; } const remove = await this.runGitCommand(`git branch -D "${branch}"`); if (remove.exitCode === 0) { deleted.push(branch); } } if (deleted.length > 0) { this.clearStaleExecutionStartBranchReferences(deleted, task.id); } return deleted; } /** * Clear `baseBranch` on any live task whose stored value matches one of the * provided (now-deleted) branch names. Prevents the scenario where a * dependent task was dispatched with baseBranch set to an upstream dep's * conflict-suffixed branch, the upstream dep was later merged and its * branch deleted, and the dependent task then failed permanently trying * to create a worktree from the vanished ref (FN-2165). * * Excludes the owner task (when provided) so a task's own archival doesn't * null its own baseBranch. * * @returns IDs of tasks whose baseBranch was cleared */ clearStaleExecutionStartBranchReferences(deletedBranches: string[], ownerTaskId?: string): string[] { if (deletedBranches.length === 0) return []; const placeholders = deletedBranches.map(() => "?").join(","); const params: string[] = [...deletedBranches]; let whereClause = `executionStartBranch IN (${placeholders})`; if (ownerTaskId) { whereClause += ` AND id != ?`; params.push(ownerTaskId); } const rows = this.db .prepare(`SELECT id FROM tasks WHERE ${whereClause}`) .all(...params) as Array<{ id: string }>; if (rows.length === 0) return []; const update = this.db.prepare( `UPDATE tasks SET executionStartBranch = NULL, updatedAt = ? WHERE id = ?`, ); const now = new Date().toISOString(); const clearedIds: string[] = []; for (const row of rows) { update.run(now, row.id); clearedIds.push(row.id); if (this.isWatching) { const cached = this.taskCache.get(row.id); if (cached) { cached.executionStartBranch = undefined; cached.updatedAt = now; } } } this.db.bumpLastModified(); return clearedIds; } private async collectMergeDetails( _id: string, _branch: string, task: Task, commitMessage: string, mergeTarget?: { branch: string; source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main" }, ): Promise { const mergedAt = new Date().toISOString(); let commitSha: string | undefined; let filesChanged: number | undefined; let insertions: number | undefined; let deletions: number | undefined; const headResult = await this.runGitCommand("git rev-parse HEAD"); if (headResult.exitCode === 0) { commitSha = headResult.stdout.trim() || undefined; } else { commitSha = undefined; } const statsResult = await this.runGitCommand("git show --shortstat --format= HEAD"); if (statsResult.exitCode === 0) { const statsOutput = statsResult.stdout.trim(); const normalized = statsOutput.replace(/\n/g, " "); const filesMatch = normalized.match(/(\d+) files? changed/); const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/); const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/); filesChanged = filesMatch ? Number.parseInt(filesMatch[1], 10) : 0; insertions = insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0; deletions = deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0; } else { filesChanged = undefined; insertions = undefined; deletions = undefined; } return { commitSha, filesChanged, insertions, deletions, mergeCommitMessage: commitMessage, mergedAt, mergeConfirmed: true, prNumber: task.prInfo?.number, mergeTargetBranch: mergeTarget?.branch, mergeTargetSource: mergeTarget?.source, resolutionStrategy: task.mergeDetails?.resolutionStrategy, resolutionMethod: task.mergeDetails?.resolutionMethod, attemptsMade: task.mergeDetails?.attemptsMade, autoResolvedCount: task.mergeDetails?.autoResolvedCount, }; } /** * Merge an in-review task's branch into the current branch, * clean up the worktree, and move the task to done. */ async mergeTask(id: string): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const branch = task.branch || `fusion/${id.toLowerCase()}`; // Branch is derived from the task id (already validated at create time), // but assert as defense-in-depth against future id-format changes. assertSafeGitBranchName(branch); if (task.column === "done") { const result: MergeResult = { task, branch, merged: false, worktreeRemoved: false, branchDeleted: false, }; const worktreePath = task.worktree; const changed = this.clearDoneTransientFields(task); if (worktreePath && existsSync(worktreePath)) { assertSafeAbsolutePath(worktreePath); const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000); if (removeWorktree.exitCode === 0) { result.worktreeRemoved = true; } } const deleteBranch = await this.runGitCommand(`git branch -d "${branch}"`); if (deleteBranch.exitCode === 0) { result.branchDeleted = true; } else { const forceDeleteBranch = await this.runGitCommand(`git branch -D "${branch}"`); if (forceDeleteBranch.exitCode === 0) { result.branchDeleted = true; } } if (changed) { task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); } result.task = task; return result; } const mergeBlocker = getTaskMergeBlocker(task); if (mergeBlocker) { throw new Error(`Cannot merge ${id}: ${mergeBlocker}`); } const worktreePath = task.worktree; const result: MergeResult = { task, branch, merged: false, worktreeRemoved: false, branchDeleted: false, }; const settings = await this.getSettings(); const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined; const mergeTarget = resolveTaskMergeTarget(task, { projectDefaultBranch, }); // 1. Check the branch exists const verifyBranch = await this.runGitCommand(`git rev-parse --verify "${branch}"`); if (verifyBranch.exitCode !== 0) { // No branch — might have been manually merged. Just move to done. result.error = `Branch '${branch}' not found — moving to done without merge`; task.mergeDetails = { mergedAt: new Date().toISOString(), mergeConfirmed: false, prNumber: task.prInfo?.number, mergeTargetBranch: mergeTarget.branch, mergeTargetSource: mergeTarget.source, }; await this.moveToDone(task, dir); result.task = { ...task, column: "done" }; this.emit("task:merged", result); return result; } const checkoutTarget = await this.runGitCommand(`git checkout "${mergeTarget.branch}"`, 120_000); if (checkoutTarget.exitCode !== 0) { throw new Error(`Unable to checkout merge target branch '${mergeTarget.branch}' for ${id}`); } // 2. Merge the branch const mergeCommitMessage = `feat(${id}): merge ${branch}`; const merge = await this.runGitCommand(`git merge --squash "${branch}"`, 120_000); const commit = merge.exitCode === 0 ? await this.runGitCommand(`git commit --no-edit -m "${mergeCommitMessage}"`, 120_000) : merge; if (merge.exitCode === 0 && commit.exitCode === 0) { result.merged = true; const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage, mergeTarget); task.mergeDetails = mergeDetails; Object.assign(result, mergeDetails); } else { // Squash conflict — reset and report await this.runGitCommand("git reset --merge"); throw new Error( `Merge conflict merging '${branch}'. Resolve manually:\n` + ` cd ${this.rootDir}\n` + ` git merge --squash ${branch}\n` + ` # resolve conflicts, then: fn task move ${id} done`, ); } // 3. Remove worktree if (worktreePath && existsSync(worktreePath)) { assertSafeAbsolutePath(worktreePath); const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000); if (removeWorktree.exitCode === 0) { result.worktreeRemoved = true; } } // 4. Delete the branch const deleteBranch = await this.runGitCommand(`git branch -d "${branch}"`); if (deleteBranch.exitCode === 0) { result.branchDeleted = true; } else { // Branch might not be fully merged in some edge cases; try force const forceDeleteBranch = await this.runGitCommand(`git branch -D "${branch}"`); if (forceDeleteBranch.exitCode === 0) { result.branchDeleted = true; } } // 5. Move task to done await this.moveToDone(task, dir); result.task = { ...task, column: "done" }; this.emit("task:merged", result); return result; }); } /** * Archive all tasks currently in the "done" column. * Returns an array of archived tasks. */ async archiveAllDone(): Promise { const doneTasks = await this.listTasks({ slim: true, column: "done" }); if (doneTasks.length === 0) { return []; } // Archive all done tasks concurrently const archivedTasks = await Promise.all( doneTasks.map((task) => this.archiveTask(task.id)) ); return archivedTasks; } /** * Archive a done task (move from done → archived). * Logs the action and emits `task:moved` event. * @param cleanup - When true, also attempts branch cleanup before writing the * cold archive entry. Active task storage is always removed. */ async archiveTask(id: string, cleanup: boolean = true): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } if (task.column !== "done") { throw new Error( `Cannot archive ${id}: task is in '${task.column}', must be in 'done'`, ); } task.column = "archived"; task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; task.log.push({ timestamp: task.columnMovedAt, action: "Task archived", }); if (!cleanup) { await this.atomicWriteTaskJson(dir, task); this.clearLinkedAgentTaskIds(id, task.updatedAt); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column }); return task; } const cleanedBranches = await this.cleanupBranchForTask(task); if (cleanedBranches.length > 0) { task.log.push({ timestamp: new Date().toISOString(), action: `Cleaned up branch: ${cleanedBranches.join(", ")}`, }); } const entry = await this.taskToArchiveEntry(task, task.columnMovedAt); this.archiveDb.upsert(entry); this.clearLinkedAgentTaskIds(id, task.updatedAt); this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id); this.db.bumpLastModified(); const { rm } = await import("node:fs/promises"); await rm(dir, { recursive: true, force: true }); if (this.isWatching) { this.taskCache.delete(id); } this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column }); return this.archiveEntryToTask(entry, false); }); } /** * Archive a task and immediately clean up its directory. * Convenience method equivalent to `archiveTask(id, true)`. */ async archiveTaskAndCleanup(id: string): Promise { return this.archiveTask(id, true); } /** * Unarchive an archived task (move from archived → done). * If the active task row was cleaned up, restores from archive.db first. * Logs the action and emits `task:moved` event. */ async unarchiveTask(id: string): Promise { const dir = this.taskDir(id); // If the active row is gone, restore from cold archive storage before // taking the task lock. A stale directory may still exist after manual // filesystem edits, so database presence is the source of truth. if (!this.readTaskFromDb(id)) { const entry = await this.findInArchive(id); if (!entry) { throw new Error( `Cannot unarchive ${id}: task is missing from active storage and not found in archive`, ); } await this.restoreFromArchive(entry); } return this.withTaskLock(id, async () => { // Re-read task.json (either existing or freshly restored) const task = await this.readTaskJson(dir); // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } if (task.column !== "archived") { throw new Error( `Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`, ); } // NOTE: No getTaskMergeBlocker check here — intentionally. // The merge blocker validates in-review → done transitions (ensuring code // has been properly reviewed before merging). An unarchived task was already // merged in its previous lifecycle; this is just a restoration. The transient // field clearing above ensures no stale blocker state leaks through. task.column = "done"; task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; // Clear transient fields that should not persist into "done" column. // Matches the clearing done by moveTask() for consistency — archived // tasks may have been archived with stale worktree/status/error/recovery // state that should not reappear after unarchiving. task.status = undefined; task.error = undefined; task.worktree = undefined; task.blockedBy = undefined; task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; task.log.push({ timestamp: task.columnMovedAt, action: "Task unarchived", }); await this.atomicWriteTaskJson(dir, task); this.archiveDb.delete(id); // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column }); return task; }); } private async moveToDone(task: Task, dir: string): Promise { if (task.column === "done") { return; } const fromColumn = task.column; const mergeBlocker = getTaskMergeBlocker(task); if (mergeBlocker) { throw new Error(`Cannot move ${task.id} to done: ${mergeBlocker}`); } task.column = "done"; this.clearDoneTransientFields(task); task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; if (!task.executionCompletedAt) { task.executionCompletedAt = task.columnMovedAt; } await this.atomicWriteTaskJson(dir, task); // Update cache if watcher is active if (this.isWatching) this.taskCache.set(task.id, { ...task }); this.emit("task:moved", { task, from: fromColumn, to: "done" as Column }); } private clearDoneTransientFields(task: Task): boolean { const changed = task.status !== undefined || task.error !== undefined || task.worktree !== undefined || task.blockedBy !== undefined || task.recoveryRetryCount !== undefined || task.nextRecoveryAt !== undefined; task.status = undefined; task.error = undefined; task.worktree = undefined; task.blockedBy = undefined; task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; return changed; } // ── File-system watcher ─────────────────────────────────────────── /** * Start watching for changes via SQLite polling. * Populates the in-memory cache and begins emitting events for * any task mutations. */ async watch(): Promise { if (this.watcher || this.pollInterval) return; // already watching this.clearStartupSlimListMemo(); // Populate cache with current state. The watcher only needs metadata to // detect created/updated/moved/deleted events; full task logs stay on the // detail path. const tasks = await this.listTasks({ slim: true, startupMemo: false }); this.taskCache.clear(); for (const task of tasks) { this.taskCache.set(task.id, { ...task }); } // Store current lastModified this.lastKnownModified = this.db.getLastModified(); // Initialize lastPollTime so the first checkForChanges() cycle filters by // "modified since now" instead of doing a full SELECT * + emitting an // update event for every cached task. Without this, dashboard startup // re-loaded the entire tasks table 1s after watch() began. this.lastPollTime = new Date().toISOString(); // Use a sentinel watcher object so existing code that checks `this.watcher` still works try { this.watcher = watch(this.tasksDir, { recursive: true }, (_event, _filename) => { // No-op - we use polling now, but keep watcher for API compat }); this.watcher.on("error", (err) => { storeLog.warn("fs.watch emitted an error; polling will continue", { phase: "watch:fs-watch-error", error: err instanceof Error ? err.message : String(err), tasksDir: this.tasksDir, }); }); } catch (err) { // fs.watch may not be available - that's fine storeLog.warn("fs.watch unavailable; falling back to polling-only updates", { phase: "watch:fs-watch-setup", error: err instanceof Error ? err.message : String(err), tasksDir: this.tasksDir, }); } // Poll for changes every second this.pollInterval = setInterval(() => { void this.checkForChanges(); }, 1000); this.clearStartupSlimListMemo(); } /** * Check for changes by comparing lastModified timestamps. * Optimized: only loads tasks modified since the last poll instead of * doing a full table scan + JSON.stringify comparison every cycle. * * This method yields to the event loop between expensive SQLite operations * to prevent blocking HTTP request handlers. Uses a pollingInProgress guard * to skip overlapping poll cycles. */ private async checkForChanges(): Promise { const startTime = Date.now(); // Guard against overlapping poll cycles if (this.pollingInProgress) return; this.pollingInProgress = true; try { const currentModified = this.db.getLastModified(); if (currentModified <= this.lastKnownModified) return; this.lastKnownModified = currentModified; // Detect deletions cheaply: compare ID sets without loading full rows. // A row missing from `tasks` can mean two things: the task was actually // deleted, OR it was archived (archiveTask removes it from `tasks` after // copying into `archived_tasks`). Other TaskStore instances polling the // same DB can't tell the difference from this view alone — without the // archive check below they emit spurious task:deleted events for every // archived task, which the activity log records as a deletion. const idRows = this.db.prepare('SELECT id FROM tasks').all() as Array<{ id: string }>; const currentIds = new Set(idRows.map((r) => r.id)); const missingIds: string[] = []; for (const id of this.taskCache.keys()) { if (!currentIds.has(id)) missingIds.push(id); } if (missingIds.length > 0) { const archivedSet = this.archiveDb.filterArchived(missingIds); for (const id of missingIds) { const cached = this.taskCache.get(id); if (!cached) continue; this.taskCache.delete(id); if (archivedSet.has(id)) { // Task moved to archive — emit task:moved (matching what // archiveTask emits in-process) so the activity-log listener // records it correctly. this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column }); } else { this.emit("task:deleted", cached); } } } // Yield to event loop before the expensive SELECT query await new Promise((resolve) => setImmediate(resolve)); // Only load tasks modified since our last known timestamp. // Use lastKnownPollTime (ISO string) to filter — much cheaper than full scan. const selectClause = this.getTaskSelectClause(true); const changedRows = this.lastPollTime ? this.db.prepare(`SELECT ${selectClause} FROM tasks WHERE updatedAt > ? OR columnMovedAt > ?`).all(this.lastPollTime, this.lastPollTime) as unknown as TaskRow[] : this.db.prepare(`SELECT ${selectClause} FROM tasks`).all() as unknown as TaskRow[]; this.lastPollTime = new Date().toISOString(); for (let i = 0; i < changedRows.length; i++) { const row = changedRows[i]; const task = this.rowToTask(row); const cached = this.taskCache.get(task.id); if (!cached) { this.taskCache.set(task.id, { ...task }); this.emit("task:created", task); } else if (cached.column !== task.column) { const from = cached.column; this.taskCache.set(task.id, { ...task }); this.emit("task:moved", { task, from, to: task.column }); } else { this.taskCache.set(task.id, { ...task }); this.emit("task:updated", task); } // Yield every ~50 rows to prevent blocking the event loop during large updates if (i > 0 && i % 50 === 0) { await new Promise((resolve) => setImmediate(resolve)); } } const elapsed = Date.now() - startTime; if (elapsed > 750) { storeLog.warn("checkForChanges took longer than expected", { elapsedMs: elapsed, thresholdMs: 750, }); } } catch (err) { storeLog.warn("checkForChanges poll cycle failed", { lastKnownModified: this.lastKnownModified, lastPollTime: this.lastPollTime, error: err instanceof Error ? err.message : String(err), }); } finally { this.pollingInProgress = false; } } /** * Stop watching and clean up. */ stopWatching(): void { if (this.watcher) { this.watcher.close(); this.watcher = null; } if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); this.taskCache.clear(); this.recentlyWritten.clear(); this.clearStartupSlimListMemo(); } /** * Mark a file path as recently written by an in-process mutation * so the watcher will skip it. */ private suppressWatcher(filePath: string): void { this.recentlyWritten.add(filePath); setTimeout(() => { this.recentlyWritten.delete(filePath); }, this.debounceMs + 100); } private static ALLOWED_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "text/plain", "text/markdown", "application/json", "text/yaml", "text/x-toml", "text/csv", "application/xml", ]); private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB async addAttachment( id: string, filename: string, content: Buffer, mimeType: string, ): Promise { if (!TaskStore.ALLOWED_MIME_TYPES.has(mimeType)) { throw new Error( `Invalid mime type '${mimeType}'. Allowed: ${[...TaskStore.ALLOWED_MIME_TYPES].join(", ")}`, ); } if (content.length > TaskStore.MAX_ATTACHMENT_SIZE) { throw new Error( `File too large (${content.length} bytes). Maximum: ${TaskStore.MAX_ATTACHMENT_SIZE} bytes (5MB)`, ); } return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const attachDir = join(dir, "attachments"); await mkdir(attachDir, { recursive: true }); // Sanitize filename: keep alphanumeric, dots, hyphens, underscores const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); const storedName = `${Date.now()}-${sanitized}`; await writeFile(join(attachDir, storedName), content); const attachment: TaskAttachment = { filename: storedName, originalName: filename, mimeType, size: content.length, createdAt: new Date().toISOString(), }; const task = await this.readTaskJson(dir); if (!task.attachments) task.attachments = []; task.attachments.push(attachment); task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return attachment; }); } async getAttachment( id: string, filename: string, ): Promise<{ path: string; mimeType: string }> { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const attachment = task.attachments?.find((a) => a.filename === filename); if (!attachment) { const err: NodeJS.ErrnoException = new Error( `Attachment '${filename}' not found on task ${id}`, ); err.code = "ENOENT"; throw err; } return { path: join(dir, "attachments", filename), mimeType: attachment.mimeType, }; } async deleteAttachment(id: string, filename: string): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const idx = task.attachments?.findIndex((a) => a.filename === filename) ?? -1; if (idx === -1) { const err: NodeJS.ErrnoException = new Error( `Attachment '${filename}' not found on task ${id}`, ); err.code = "ENOENT"; throw err; } // Remove file from disk const filePath = join(dir, "attachments", filename); try { await unlink(filePath); } catch { // File may already be gone } task.attachments!.splice(idx, 1); if (task.attachments!.length === 0) { task.attachments = undefined; } task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } /** * Insert an agent log entry into the agentLogEntries SQLite table. * Also emits an `agent:log` event for live streaming. * * @param taskId - The task ID (e.g. "KB-001") * @param text - The text content (delta for "text"/"thinking", tool name for "tool"/"tool_result"/"tool_error") * @param type - The entry type discriminator * @param detail - Optional human-readable summary (tool args, result summary, or error message) * @param agent - Optional agent role that produced this entry */ async appendAgentLog( taskId: string, text: string, type: AgentLogEntry["type"], detail?: string, agent?: AgentLogEntry["agent"], ): Promise { const timestamp = new Date().toISOString(); const normalizedDetail = truncateAgentLogDetail(detail, type); const entry: AgentLogEntry = { timestamp, taskId, text, type, ...(normalizedDetail !== undefined && { detail: normalizedDetail }), ...(agent !== undefined && { agent }), }; // Buffer the entry for batched insertion to reduce WAL pressure. // Drop oldest entries if backlog exceeds hard cap (prolonged outage). if (this.agentLogBuffer.length >= TaskStore.MAX_AGENT_LOG_BACKLOG) { const dropCount = this.agentLogBuffer.length - TaskStore.MAX_AGENT_LOG_BACKLOG + 1; this.agentLogBuffer.splice(0, dropCount); console.warn( `[fusion] Dropped ${dropCount} buffered agent log entries — backlog cap reached (${this.db.path})`, ); } this.agentLogBuffer.push({ taskId, timestamp, text, type, detail: normalizedDetail ?? null, agent: agent ?? null, }); this.emit("agent:log", entry); if (this.agentLogBuffer.length >= TaskStore.AGENT_LOG_BUFFER_SIZE) { try { this.flushAgentLogBuffer(); } catch (err) { // Size-triggered flush failed — log but don't crash the caller. console.error(`[fusion] Size-triggered agent log flush failed (${this.db.path}):`, err); } } else if (!this.agentLogFlushTimer) { this.agentLogFlushTimer = setTimeout( () => { try { this.flushAgentLogBuffer(); } catch (err) { // Timer-triggered flush failed — log but don't crash the process. console.error(`[fusion] Timer-triggered agent log flush failed (${this.db.path}):`, err); } }, TaskStore.AGENT_LOG_FLUSH_MS, ); this.agentLogFlushTimer.unref(); } } /** * Flush all buffered agent log entries in a single transaction. * Called when the buffer is full or on a timer. */ private flushAgentLogBuffer(): void { if (this.agentLogFlushTimer) { clearTimeout(this.agentLogFlushTimer); this.agentLogFlushTimer = null; } if (this.agentLogBuffer.length === 0) return; // Snapshot the entries to flush. New entries appended during the // synchronous transaction will appear past batch.length in // this.agentLogBuffer, so we splice only the flushed count. const batch = this.agentLogBuffer.slice(); const flushCount = batch.length; let validEntries = batch; let flushSucceeded = false; try { this.db.transaction(() => { // Query live task IDs inside the transaction so the check is // atomic with the inserts (prevents TOCTOU FK violations). const liveTaskIds = new Set( (this.db.prepare("SELECT id FROM tasks").all() as Array<{ id: string }>).map((r) => r.id), ); validEntries = batch.filter((e) => liveTaskIds.has(e.taskId)); const dropped = batch.length - validEntries.length; if (dropped > 0) { console.warn( `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${this.db.path})`, ); } if (validEntries.length > 0) { const stmt = this.db.prepare(` INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) VALUES (?, ?, ?, ?, ?, ?) `); for (const entry of validEntries) { stmt.run(entry.taskId, entry.timestamp, entry.text, entry.type, entry.detail, entry.agent); } this.db.bumpLastModified(); } }); flushSucceeded = true; } finally { // Always drain the original slice from the buffer. this.agentLogBuffer.splice(0, flushCount); // On transient failures (busy/IO), requeue valid entries for retry. // Stale rows were already filtered out above. if (!flushSucceeded && validEntries.length > 0) { this.agentLogBuffer.unshift(...validEntries); // Re-arm the flush timer so retried entries don't sit in memory forever. if (!this.agentLogFlushTimer) { this.agentLogFlushTimer = setTimeout(() => { try { this.flushAgentLogBuffer(); } catch (err) { console.error(`[fusion] Retry agent log flush failed (${this.db.path}):`, err); } }, TaskStore.AGENT_LOG_FLUSH_MS); this.agentLogFlushTimer.unref(); } } } } async appendAgentLogBatch( entries: Array<{ taskId: string; text: string; type: AgentLogEntry["type"]; detail?: string; agent?: AgentLogEntry["agent"]; }>, ): Promise { if (entries.length === 0) { return; } // Flush buffered single-entry appends so they land before batch entries, // preserving insertion order (same-timestamp entries are ordered by rowid). this.flushAgentLogBuffer(); const timestamp = new Date().toISOString(); const normalizedEntries = entries.map((entry) => ({ ...entry, detail: truncateAgentLogDetail(entry.detail, entry.type), })); const stmt = this.db.prepare(` INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) VALUES (?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const entry of normalizedEntries) { stmt.run( entry.taskId, timestamp, entry.text, entry.type, entry.detail ?? null, entry.agent ?? null, ); } this.db.bumpLastModified(); }); for (const entry of normalizedEntries) { this.emit("agent:log", { timestamp, taskId: entry.taskId, text: entry.text, type: entry.type, ...(entry.detail !== undefined && { detail: entry.detail }), ...(entry.agent !== undefined && { agent: entry.agent }), }); } } private mapAgentLogRow(row: Record): AgentLogEntry { const type = row.type as AgentLogEntry["type"]; const detail = row.detail != null ? String(row.detail) : undefined; return { timestamp: row.timestamp as string, taskId: row.taskId as string, text: row.text as string, type, ...(detail !== undefined && { detail }), ...(row.agent != null && { agent: row.agent as AgentLogEntry["agent"] }), }; } private getAgentLogSelectClause(): string { const escapedNotice = AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE.replace(/'/g, "''"); return ` taskId, timestamp, text, type, CASE WHEN type IN ('tool', 'tool_result', 'tool_error') AND detail IS NOT NULL AND LENGTH(detail) > ${AGENT_LOG_TOOL_DETAIL_LIMIT} THEN SUBSTR(detail, 1, ${AGENT_LOG_TOOL_DETAIL_LIMIT}) || '${escapedNotice}' ELSE detail END AS detail, agent `; } async addTaskComment(id: string, text: string, author: string): Promise { // Delegate to unified addComment method return this.addComment(id, text, author); } /** * Add a steering comment to a task. * Steering comments are injected into the AI execution context. * They are stored in BOTH `comments` (for unified UI display) and * `steeringComments` (for executor real-time injection). * Unlike regular comments, steering comments never trigger auto-refinement. */ async addSteeringComment(id: string, text: string, author: "user" | "agent" = "user", runContext?: RunMutationContext): Promise { // Write to unified comments (skip refinement — steering is for agent injection, not follow-up tasks) const task = await this.addComment(id, text, author, { skipRefinement: true }, runContext); // Also write to steeringComments so the executor's real-time injection listener can detect new entries const updated = await this.withTaskLock(id, async () => { const dir = this.taskDir(id); const currentTask = await this.readTaskJson(dir); const steeringComment: import("./types.js").SteeringComment = { id: task.comments![task.comments!.length - 1].id, text, createdAt: new Date().toISOString(), author, }; if (!currentTask.steeringComments) { currentTask.steeringComments = []; } currentTask.steeringComments.push(steeringComment); currentTask.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, currentTask); if (this.isWatching) this.taskCache.set(id, { ...currentTask }); this.emit("task:updated", currentTask); return currentTask; }); return updated; } async updateTaskComment(id: string, commentId: string, text: string): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const comments = task.comments || []; const comment = comments.find((entry) => entry.id === commentId); if (!comment) { throw new Error(`Comment ${commentId} not found on task ${id}`); } comment.text = text; comment.updatedAt = new Date().toISOString(); task.comments = comments; task.updatedAt = comment.updatedAt; task.log.push({ timestamp: task.updatedAt, action: "Comment updated", }); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } async deleteTaskComment(id: string, commentId: string): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const currentComments = task.comments || []; const nextComments = currentComments.filter((entry) => entry.id !== commentId); if (nextComments.length === currentComments.length) { throw new Error(`Comment ${commentId} not found on task ${id}`); } task.comments = nextComments.length > 0 ? nextComments : undefined; task.updatedAt = new Date().toISOString(); task.log.push({ timestamp: task.updatedAt, action: "Comment deleted", }); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } /** * Add a comment to a task. * Comments are injected into the AI execution context. * When a comment is added to a task in the "done" column by a user, * automatically creates a refinement task with the comment text as feedback. * * Note: Now uses the unified comments system (TaskComment). */ async addComment( id: string, text: string, author: string = "user", options?: { skipRefinement?: boolean }, runContext?: RunMutationContext, ): Promise { // Phase 1: Add comment under lock const task = await this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); // Initialize log array if missing (for legacy tasks) if (!task.log) { task.log = []; } // Generate unique ID: timestamp + random suffix for collision resistance const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const comment: import("./types.js").TaskComment = { id: commentId, text, author, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; if (!task.comments) { task.comments = []; } task.comments.push(comment); task.updatedAt = new Date().toISOString(); const logEntry: TaskLogEntry = { timestamp: task.updatedAt, action: `Comment added by ${author}`, }; if (runContext) { logEntry.runContext = runContext; } task.log.push(logEntry); // When runContext is provided, record audit event atomically with task mutation if (runContext) { await this.atomicWriteTaskJsonWithAudit(dir, task, { taskId: task.id, agentId: runContext.agentId, runId: runContext.runId, domain: "database", mutationType: "task:comment", target: task.id, metadata: { author, commentId }, }); } else { await this.atomicWriteTaskJson(dir, task); } if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); const commentContextBase: Record = { taskId: id, author, commentLength: text.length, column: task.column, priorStatus: task.status ?? null, }; if (runContext) { commentContextBase.runId = runContext.runId; commentContextBase.agentId = runContext.agentId; if (runContext.source) { commentContextBase.runSource = runContext.source; } } // Phase 2: Auto-refinement OUTSIDE the lock (to avoid lock contention) // Only create refinement for user comments on done tasks. // This remains best-effort: failures are logged for observability but never // fail the comment add operation itself. // Steering comments skip refinement — they are injected into the agent stream instead. if (task.column === "done" && author === "user" && !options?.skipRefinement) { try { await this.refineTask(id, text); } catch (err) { storeLog.warn("Best-effort post-comment auto-refinement failed", { ...commentContextBase, phase: "addComment:auto-refinement", error: err instanceof Error ? err.message : String(err), }); } } // Phase 3: user comments on already-planned, non-executing work should // trigger triage re-specification. This includes awaiting-approval // invalidation and todo/triage tasks that have a real non-bootstrap spec. // This remains best-effort: failures are logged for observability but // never fail the comment add operation itself. // Note: The `task` returned above reflects the state BEFORE this // transition. Callers that need the post-transition status should // re-read the task (e.g., via getTask). if (author === "user" && (task.column === "todo" || task.column === "triage")) { let hasRealPrompt = false; try { const promptPath = join(this.taskDir(id), "PROMPT.md"); if (existsSync(promptPath)) { const prompt = await readFile(promptPath, "utf-8"); hasRealPrompt = !isBootstrapPromptStub(prompt, task.id, task.title, task.description); } } catch (err) { storeLog.warn("Best-effort post-comment re-triage prompt-read failed", { ...commentContextBase, phase: "addComment:retriage-prompt-read", error: err instanceof Error ? err.message : String(err), }); } const shouldInvalidateAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; const shouldRetriagePlannedTask = hasRealPrompt && ( task.column === "todo" || (task.column === "triage" && task.status !== "awaiting-approval") ); if (shouldInvalidateAwaitingApproval || shouldRetriagePlannedTask) { const phase = shouldInvalidateAwaitingApproval ? "addComment:awaiting-approval-invalidation" : "addComment:planned-task-retriage"; const action = shouldInvalidateAwaitingApproval ? "User comment invalidated spec approval — task needs re-specification" : "User comment requested re-specification of planned task"; let transitioned = false; try { await this.updateTask(id, { status: "needs-replan" }); transitioned = true; } catch (err) { storeLog.warn("Best-effort post-comment re-triage failed", { ...commentContextBase, phase, stage: "status-update", nextStatus: "needs-replan", error: err instanceof Error ? err.message : String(err), }); } if (transitioned) { try { await this.logEntry(id, action, text, runContext); } catch (err) { storeLog.warn("Best-effort post-comment re-triage failed", { ...commentContextBase, phase, stage: "post-invalidation-log-entry", nextStatus: "needs-replan", error: err instanceof Error ? err.message : String(err), }); } } } } return task; } /** * List all current task documents for a task, ordered by key. */ async getTaskDocuments(taskId: string): Promise { const rows = this.db .prepare("SELECT * FROM task_documents WHERE taskId = ? ORDER BY key") .all(taskId) as unknown as TaskDocumentRow[]; return rows.map((row) => this.rowToTaskDocument(row)); } /** * List all documents across all tasks, optionally filtered by search query. * Each document includes its parent task's title and column for display. */ async getAllDocuments(options?: { searchQuery?: string; limit?: number; offset?: number; }): Promise { const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000); const offset = Math.max(0, options?.offset ?? 0); let sql = ` SELECT td.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn FROM task_documents td JOIN tasks t ON td.taskId = t.id `; const params: (string | number)[] = []; if (options?.searchQuery && options.searchQuery.trim() !== "") { const query = `%${options.searchQuery.trim()}%`; sql += ` WHERE td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?`; params.push(query, query, query); } sql += ` ORDER BY td.updatedAt DESC LIMIT ? OFFSET ?`; params.push(limit, offset); const rows = this.db.prepare(sql).all(...params) as unknown as (TaskDocumentRow & { taskTitle: string; taskDescription: string; taskColumn: string })[]; return rows.map((row) => { const doc = this.rowToTaskDocument(row); return { ...doc, taskTitle: row.taskTitle, taskDescription: row.taskDescription, taskColumn: row.taskColumn, }; }); } /** * Get the current revision of a specific task document. */ async getTaskDocument(taskId: string, key: string): Promise { const row = this.db .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") .get(taskId, key) as unknown as TaskDocumentRow | undefined; if (!row) return null; return this.rowToTaskDocument(row); } /** * Create or update a task document while archiving previous revisions. */ async upsertTaskDocument(taskId: string, input: TaskDocumentCreateInput): Promise { try { validateDocumentKey(input.key); } catch { throw new Error( `Invalid document key: "${input.key}". Must be 1-64 alphanumeric characters, hyphens, or underscores.`, ); } const taskExists = this.db.prepare('SELECT id, "column" FROM tasks WHERE id = ?').get(taskId) as | { id: string; column: Column } | undefined; if (taskExists?.column === "archived") { throw new Error(`Task ${taskId} is archived — documents are read-only`); } if (!taskExists) { if (this.isTaskArchived(taskId)) { throw new Error(`Task ${taskId} is archived — documents are read-only`); } throw new Error(`Task ${taskId} not found`); } const now = new Date().toISOString(); const author = input.author ?? "user"; const metadata = toJsonNullable(input.metadata); const document = this.db.transaction(() => { const existing = this.db .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") .get(taskId, input.key) as TaskDocumentRow | undefined; if (existing) { this.db.prepare( `INSERT INTO task_document_revisions (taskId, key, content, revision, author, metadata, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run( taskId, input.key, existing.content, existing.revision, existing.author, existing.metadata ?? null, now, ); this.db.prepare( `UPDATE task_documents SET content = ?, revision = ?, author = ?, metadata = ?, updatedAt = ? WHERE taskId = ? AND key = ?` ).run( input.content, existing.revision + 1, author, metadata, now, taskId, input.key, ); } else { this.db.prepare( `INSERT INTO task_documents (id, taskId, key, content, revision, author, metadata, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( randomUUID(), taskId, input.key, input.content, 1, author, metadata, now, now, ); } const row = this.db .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") .get(taskId, input.key) as TaskDocumentRow | undefined; if (!row) { throw new Error(`Failed to upsert document ${input.key} for task ${taskId}`); } return this.rowToTaskDocument(row); }); this.db.bumpLastModified(); const task = await this.getTask(taskId); this.emit("task:updated", task); return document; } /** * List archived revisions for a task document, newest first. */ async getTaskDocumentRevisions( taskId: string, key: string, options?: { limit?: number }, ): Promise { const hasLimit = options?.limit !== undefined; const rows = hasLimit ? (this.db .prepare( "SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC LIMIT ?", ) .all(taskId, key, Math.max(0, options.limit ?? 0)) as unknown as TaskDocumentRevisionRow[]) : (this.db .prepare( "SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC", ) .all(taskId, key) as unknown as TaskDocumentRevisionRow[]); return rows.map((row) => this.rowToTaskDocumentRevision(row)); } /** * Delete a task document and all archived revisions for its key. */ async deleteTaskDocument(taskId: string, key: string): Promise { const existing = this.db .prepare("SELECT id FROM task_documents WHERE taskId = ? AND key = ?") .get(taskId, key) as { id: string } | undefined; if (!existing) { throw new Error(`Document ${key} not found for task ${taskId}`); } this.db.transaction(() => { this.db .prepare("DELETE FROM task_document_revisions WHERE taskId = ? AND key = ?") .run(taskId, key); const result = this.db .prepare("DELETE FROM task_documents WHERE taskId = ? AND key = ?") .run(taskId, key) as { changes?: number }; if ((result.changes ?? 0) === 0) { throw new Error(`Document ${key} not found for task ${taskId}`); } }); this.db.bumpLastModified(); const task = await this.getTask(taskId); this.emit("task:updated", task); } /** * Update or clear PR information for a task. * Updates task.json atomically and emits `task:updated` event. * * @param id - The task ID * @param prInfo - The PR info to set, or null to clear * @returns The updated task */ async updatePrInfo( id: string, prInfo: import("./types.js").PrInfo | null, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const previous = task.prInfo; const badgeChanged = previous?.url !== prInfo?.url || previous?.number !== prInfo?.number || previous?.status !== prInfo?.status || previous?.title !== prInfo?.title || previous?.headBranch !== prInfo?.headBranch || previous?.baseBranch !== prInfo?.baseBranch || previous?.commentCount !== prInfo?.commentCount || previous?.lastCommentAt !== prInfo?.lastCommentAt; const linkChanged = previous?.number !== prInfo?.number || previous?.url !== prInfo?.url; if (prInfo) { task.prInfo = prInfo; if (!previous || linkChanged) { task.log.push({ timestamp: new Date().toISOString(), action: "PR linked", outcome: `PR #${prInfo.number}: ${prInfo.url}`, }); } else if (badgeChanged) { task.log.push({ timestamp: new Date().toISOString(), action: "PR updated", outcome: `PR #${prInfo.number} badge metadata refreshed`, }); } } else { task.prInfo = undefined; if (previous?.number) { task.log.push({ timestamp: new Date().toISOString(), action: "PR unlinked", outcome: `PR #${previous.number} removed`, }); } } task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); if (badgeChanged) { this.emit("task:updated", task); } return task; }); } /** * Update or clear Issue information for a task. * Updates task.json atomically and emits `task:updated` event. * * @param id - The task ID * @param issueInfo - The Issue info to set, or null to clear * @returns The updated task */ async updateIssueInfo( id: string, issueInfo: import("./types.js").IssueInfo | null, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const previous = task.issueInfo; const badgeChanged = previous?.url !== issueInfo?.url || previous?.number !== issueInfo?.number || previous?.state !== issueInfo?.state || previous?.title !== issueInfo?.title || previous?.stateReason !== issueInfo?.stateReason; const linkChanged = previous?.number !== issueInfo?.number || previous?.url !== issueInfo?.url; if (issueInfo) { task.issueInfo = issueInfo; if (!previous || linkChanged) { task.log.push({ timestamp: new Date().toISOString(), action: "Issue linked", outcome: `Issue #${issueInfo.number}: ${issueInfo.url}`, }); } else if (badgeChanged) { task.log.push({ timestamp: new Date().toISOString(), action: "Issue updated", outcome: `Issue #${issueInfo.number} badge metadata refreshed`, }); } } else { task.issueInfo = undefined; if (previous?.number) { task.log.push({ timestamp: new Date().toISOString(), action: "Issue unlinked", outcome: `Issue #${previous.number} removed`, }); } } task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); if (badgeChanged) { this.emit("task:updated", task); } return task; }); } async updateGithubTracking( id: string, tracking: import("./types.js").TaskGithubTracking | null, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const nextTracking = tracking ?? undefined; const previousTracking = task.githubTracking; if (JSON.stringify(previousTracking ?? null) === JSON.stringify(nextTracking ?? null)) { return task; } task.githubTracking = nextTracking; task.log.push({ timestamp: new Date().toISOString(), action: tracking?.enabled === false ? "GitHub tracking disabled" : "GitHub tracking enabled", }); task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } async linkGithubIssue( id: string, issue: import("./types.js").TaskGithubTrackedIssue, ): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const previous = task.githubTracking ?? {}; const nextTracking: import("./types.js").TaskGithubTracking = { ...previous, issue, enabled: previous.enabled ?? true, }; if (JSON.stringify(previous) === JSON.stringify(nextTracking)) { return task; } task.githubTracking = nextTracking; task.log.push({ timestamp: new Date().toISOString(), action: "GitHub issue linked", outcome: `${issue.owner}/${issue.repo}#${issue.number}`, }); task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } async unlinkGithubIssue(id: string): Promise { return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); const previous = task.githubTracking; const previousIssue = previous?.issue; if (!previousIssue || !previous) { return task; } task.githubTracking = { ...previous, issue: undefined, unlinkedAt: new Date().toISOString(), }; task.log.push({ timestamp: new Date().toISOString(), action: "GitHub issue unlinked", outcome: `${previousIssue.owner}/${previousIssue.repo}#${previousIssue.number}`, }); task.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); return task; }); } /** * Read historical agent log entries for a task from SQLite. * Returns entries in chronological order (oldest first). * * Tool-oriented detail payloads are clipped server-side to keep historical * log reads responsive even when agents emit very large command results. * The 500-entry cap (`MAX_LOG_ENTRIES`) in the dashboard hooks remains a * whole-list limit only. * * @param taskId - The task ID (e.g. "KB-001") * @param options - Optional pagination options * @param options.limit - Maximum number of entries to return (most recent) * @param options.offset - Number of most-recent entries to skip (for pagination) * @returns Array of agent log entries */ async getAgentLogs( taskId: string, options?: { limit?: number; offset?: number }, ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); const limit = options?.limit !== undefined ? (Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0) : undefined; const offset = options?.offset !== undefined ? (Number.isFinite(options.offset) ? Math.max(0, Math.floor(options.offset)) : 0) : 0; if (limit === 0) return []; const selectClause = this.getAgentLogSelectClause(); if (limit !== undefined) { const readCount = offset > 0 ? limit + offset : limit; const rows = this.db.prepare(` SELECT ${selectClause} FROM agentLogEntries WHERE taskId = ? ORDER BY timestamp DESC, id DESC LIMIT ? `).all(taskId, readCount) as Array>; const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse(); if (offset > 0) { return entries.slice(0, Math.max(0, entries.length - offset)); } return entries; } const rows = this.db.prepare(` SELECT ${selectClause} FROM agentLogEntries WHERE taskId = ? ORDER BY timestamp ASC, id ASC `).all(taskId) as Array>; const entries = rows.map((row) => this.mapAgentLogRow(row)); if (offset > 0) { return entries.slice(0, Math.max(0, entries.length - offset)); } return entries; } /** * Count total number of persisted agent log entries for a task in SQLite. * * @param taskId - The task ID (e.g. "KB-001") * @returns Total number of log entries */ async getAgentLogCount(taskId: string): Promise { this.flushAgentLogBuffer(); const row = this.db.prepare( "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", ).get(taskId) as { count: number } | undefined; return row?.count ?? 0; } /** * Get persisted agent log entries for a task filtered by an inclusive time range. * * @param taskId - The task ID (e.g. "KB-001") * @param startIso - ISO-8601 start timestamp (inclusive) * @param endIso - ISO-8601 end timestamp (inclusive), or null for "now" * @returns Filtered array of agent log entries */ async getAgentLogsByTimeRange( taskId: string, startIso: string, endIso: string | null, ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); const end = endIso ?? new Date().toISOString(); const selectClause = this.getAgentLogSelectClause(); const rows = this.db.prepare(` SELECT ${selectClause} FROM agentLogEntries WHERE taskId = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC, id ASC `).all(taskId, startIso, end) as Array>; return rows.map((row) => this.mapAgentLogRow(row)); } async importLegacyAgentLogs(): Promise { if (!existsSync(this.tasksDir)) return 0; const entries = await readdir(this.tasksDir, { withFileTypes: true }); let imported = 0; const insertStmt = this.db.prepare(` INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) VALUES (?, ?, ?, ?, ?, ?) `); for (const entry of entries) { if (!entry.isDirectory()) continue; const logPath = join(this.tasksDir, entry.name, "agent.log"); if (!existsSync(logPath)) continue; try { const content = await readFile(logPath, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; try { const parsed = JSON.parse(trimmed) as Record; const timestamp = typeof parsed.timestamp === "string" ? parsed.timestamp : null; const parsedTaskId = typeof parsed.taskId === "string" ? parsed.taskId : null; const type = typeof parsed.type === "string" ? parsed.type : null; if (!timestamp || !parsedTaskId || !type) continue; const text = typeof parsed.text === "string" ? parsed.text : ""; const detail = typeof parsed.detail === "string" ? parsed.detail : null; const agent = typeof parsed.agent === "string" ? parsed.agent : null; const normalizedDetail = truncateAgentLogDetail( detail, type as AgentLogEntry["type"], ); insertStmt.run(parsedTaskId, timestamp, text, type, normalizedDetail ?? null, agent); imported += 1; } catch { // Skip malformed JSONL lines. } } } catch (err) { storeLog.warn("Skipping unreadable legacy agent.log file during import", { phase: "importLegacyAgentLogs:read-file", taskId: entry.name, logPath, error: err instanceof Error ? err.message : String(err), }); } } if (imported > 0) { this.db.bumpLastModified(); } return imported; } private async importLegacyAgentLogsOnce(): Promise { const migrationKey = "agentLogLegacyFileImportVersion"; const migrationVersion = "1"; const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as | { value: string } | undefined; if (row?.value === migrationVersion) { return; } await this.importLegacyAgentLogs(); this.db.prepare(` INSERT INTO __meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `).run(migrationKey, migrationVersion); this.db.bumpLastModified(); } // ── Archive Cleanup Methods ───────────────────────────────────────── /** * Read all archived task entries from SQLite. */ async readArchiveLog(): Promise { return this.archiveDb.list(); } /** * Find a specific task in the archive by ID. */ async findInArchive(id: string): Promise { return this.archiveDb.get(id); } private migrateLegacyArchiveEntriesToArchiveDb(): void { const rows = this.db.prepare("SELECT id, data FROM archivedTasks").all() as Array<{ id: string; data: string }>; if (rows.length === 0) { return; } for (const row of rows) { const entry = JSON.parse(row.data) as ArchivedTaskEntry; this._archiveDb?.upsert({ ...entry, log: compactTaskActivityLog(entry.log ?? []), }); } this.db.prepare("DELETE FROM archivedTasks").run(); this.db.bumpLastModified(); } private async migrateActiveArchivedTasksToArchiveDb(): Promise { const rows = this.db.prepare(`SELECT * FROM tasks WHERE "column" = 'archived'`).all() as unknown as TaskRow[]; if (rows.length === 0) { return; } const { rm } = await import("node:fs/promises"); for (const row of rows) { const task = this.rowToTask(row); const archivedAt = task.columnMovedAt ?? task.updatedAt ?? new Date().toISOString(); const entry = await this.taskToArchiveEntry(task, archivedAt); this.archiveDb.upsert(entry); this.db.prepare("DELETE FROM tasks WHERE id = ?").run(task.id); await rm(this.taskDir(task.id), { recursive: true, force: true }); if (this.isWatching) { this.taskCache.delete(task.id); } } this.db.bumpLastModified(); } /** * Cleanup any legacy active archived tasks by writing compact entries to * archive.db and removing task directories. */ async cleanupArchivedTasks(): Promise { const archivedTasks = await this.listTasks({ column: "archived" }); const cleanedUpIds: string[] = []; for (const task of archivedTasks) { const dir = this.taskDir(task.id); // Skip if directory already cleaned up if (!existsSync(dir)) { continue; } const entry = await this.taskToArchiveEntry(task, new Date().toISOString()); this.archiveDb.upsert(entry); // Remove task from tasks table this.db.prepare('DELETE FROM tasks WHERE id = ?').run(task.id); this.db.bumpLastModified(); // Remove task directory recursively const { rm } = await import("node:fs/promises"); await rm(dir, { recursive: true, force: true }); // Remove from cache if watcher is active if (this.isWatching) { this.taskCache.delete(task.id); } cleanedUpIds.push(task.id); } return cleanedUpIds; } /** * Restore a task from an archive entry. * Recreates task directory with task.json and PROMPT.md. * Clears transient execution state (worktree, status, blockedBy, etc.). * Agent log entries are stored in SQLite and are deleted by FK cascade when * the task row is removed; archive snapshots (`agentLogFull`/`agentLogSnapshot`) * preserve point-in-time log data inside the archived task record. */ private async restoreFromArchive(entry: import("./types.js").ArchivedTaskEntry): Promise { const dir = this.taskDir(entry.id); // Create task directory await mkdir(dir, { recursive: true }); // Build restored task (clear transient fields) const restoredTask: Task = { id: entry.id, lineageId: entry.lineageId || generateTaskLineageId(), title: entry.title, description: entry.description, priority: normalizeTaskPriority(entry.priority), column: "archived", // Will be changed to "done" by unarchiveTask dependencies: entry.dependencies, steps: entry.steps, currentStep: entry.currentStep, size: entry.size, reviewLevel: entry.reviewLevel, prInfo: entry.prInfo, review: entry.review, issueInfo: entry.issueInfo, githubTracking: entry.githubTracking, sourceIssue: entry.sourceIssue, attachments: entry.attachments, log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }], comments: entry.comments, createdAt: entry.createdAt, updatedAt: new Date().toISOString(), columnMovedAt: entry.columnMovedAt, modelPresetId: entry.modelPresetId, modelProvider: entry.modelProvider, modelId: entry.modelId, validatorModelProvider: entry.validatorModelProvider, validatorModelId: entry.validatorModelId, planningModelProvider: entry.planningModelProvider, planningModelId: entry.planningModelId, breakIntoSubtasks: entry.breakIntoSubtasks, modifiedFiles: entry.modifiedFiles, // Intentionally NOT restoring: worktree, status, blockedBy, paused, executionStartBranch, baseCommitSha, error }; // Write task.json await this.atomicWriteTaskJson(dir, restoredTask); // Generate PROMPT.md with preserved steps const prompt = entry.prompt ?? this.generatePromptFromArchiveEntry(entry); const validation = validateFileScopeInPromptContent(prompt); if (validation.invalid.length > 0) { throw new InvalidFileScopeError(entry.id, validation.invalid); } await mkdir(dir, { recursive: true }); await writeFile(join(dir, "PROMPT.md"), prompt); // Create empty attachments directory if attachments existed if (entry.attachments && entry.attachments.length > 0) { await mkdir(join(dir, "attachments"), { recursive: true }); } return restoredTask; } /** * Generate a PROMPT.md from an archive entry, preserving the original step structure. */ private generatePromptFromArchiveEntry(entry: import("./types.js").ArchivedTaskEntry): string { const deps = entry.dependencies.length > 0 ? entry.dependencies.map((d) => `- **Task:** ${d}`).join("\n") : "- **None**"; const heading = entry.title ? `${entry.id}: ${entry.title}` : entry.id; // Build steps section from preserved steps let stepsSection = "## Steps\n\n"; if (entry.steps && entry.steps.length > 0) { for (let i = 0; i < entry.steps.length; i++) { const step = entry.steps[i]; const status = step.status === "done" ? "[x]" : "[ ]"; stepsSection += `### Step ${i}: ${step.name}\n\n- ${status} ${step.name}\n\n`; } } else { stepsSection += "### Step 0: Preflight\n\n- [ ] Review and verify\n\n"; } return `# ${heading} **Created:** ${entry.createdAt.split("T")[0]} ${entry.size ? `**Size:** ${entry.size}` : "**Size:** M"} ## Mission ${entry.description} ## Dependencies ${deps} ${stepsSection}`; } // ── Workflow Step CRUD Methods ───────────────────────────────────── /** * Create a new workflow step definition. * Generates a unique ID (WS-001, WS-002, etc.) and stores in the workflow_steps table. */ async createWorkflowStep(input: import("./types.js").WorkflowStepInput): Promise { return this.withConfigLock(async () => { const counterRow = this.db .prepare("SELECT nextWorkflowStepId FROM config WHERE id = 1") .get() as { nextWorkflowStepId?: number } | undefined; const nextWsId = counterRow?.nextWorkflowStepId || 1; const id = `WS-${String(nextWsId).padStart(3, "0")}`; const mode = input.mode || "prompt"; const gateMode = input.gateMode || (mode === "script" ? "gate" : "advisory"); // Validate: script mode requires scriptName if (mode === "script" && !input.scriptName?.trim()) { throw new Error("Script mode requires a scriptName"); } const now = new Date().toISOString(); const step: import("./types.js").WorkflowStep = { id, templateId: input.templateId, name: input.name, description: input.description, mode, phase: input.phase || "pre-merge", gateMode, prompt: mode === "prompt" ? (input.prompt || "") : "", toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined, scriptName: mode === "script" ? input.scriptName : undefined, enabled: input.enabled !== undefined ? input.enabled : true, defaultOn: input.defaultOn !== undefined ? input.defaultOn : undefined, modelProvider: mode === "prompt" ? input.modelProvider : undefined, modelId: mode === "prompt" ? input.modelId : undefined, createdAt: now, updatedAt: now, }; this.db.prepare( `INSERT INTO workflow_steps ( id, templateId, name, description, mode, phase, gateMode, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId, createdAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( step.id, step.templateId ?? null, step.name, step.description, step.mode, step.phase || "pre-merge", step.gateMode, step.prompt, step.toolMode ?? null, step.scriptName ?? null, step.enabled ? 1 : 0, step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, step.modelProvider ?? null, step.modelId ?? null, step.createdAt, step.updatedAt, ); const config = await this.readConfig(); await this.writeConfig(config, { nextWorkflowStepId: nextWsId + 1 }); this.workflowStepsCache = null; return step; }); } setPluginWorkflowStepTemplates(templates: Array<{ pluginId: string; template: WorkflowStepTemplate }>): void { this._pluginWorkflowStepTemplates = [...templates]; this.workflowStepsCache = null; } private resolvePluginWorkflowStep(id: string): import("./types.js").WorkflowStep | undefined { const match = id.match(/^plugin:([^:]+):(.+)$/); if (!match) return undefined; const [, pluginId, stepId] = match; const entry = this._pluginWorkflowStepTemplates.find( ({ pluginId: candidatePluginId, template }) => candidatePluginId === pluginId && template.id === id, ); if (!entry) return undefined; const now = new Date().toISOString(); return { id, templateId: stepId, name: entry.template.name, description: entry.template.description, mode: entry.template.mode ?? "prompt", phase: entry.template.phase ?? "pre-merge", gateMode: entry.template.gateMode ?? ((entry.template.mode ?? "prompt") === "script" ? "gate" : "advisory"), prompt: entry.template.prompt ?? "", scriptName: entry.template.scriptName, toolMode: entry.template.toolMode, enabled: entry.template.enabled ?? true, defaultOn: entry.template.defaultOn, modelProvider: entry.template.modelProvider, modelId: entry.template.modelId, createdAt: now, updatedAt: now, }; } /** * List all workflow step definitions from workflow_steps. * Results are cached and invalidated on create/update/delete. */ async listWorkflowSteps(): Promise { if (this.workflowStepsCache) return this.workflowStepsCache; const rows = this.db.prepare("SELECT * FROM workflow_steps ORDER BY createdAt ASC").all() as Array<{ id: string; templateId: string | null; name: string; description: string; mode: string; phase: string | null; prompt: string; gateMode: string | null; toolMode: string | null; scriptName: string | null; enabled: number; defaultOn: number | null; modelProvider: string | null; modelId: string | null; createdAt: string; updatedAt: string; }>; const storedSteps = rows.map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row))); const pluginSteps = this._pluginWorkflowStepTemplates .map(({ template }) => this.resolvePluginWorkflowStep(template.id)) .filter((step): step is import("./types.js").WorkflowStep => Boolean(step)); this.workflowStepsCache = [...storedSteps, ...pluginSteps]; return this.workflowStepsCache; } /** * Get a single workflow step by ID. */ async getWorkflowStep(id: string): Promise { if (id.startsWith("plugin:")) { const pluginStep = this.resolvePluginWorkflowStep(id); if (pluginStep) { return pluginStep; } } const byId = this.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as | { id: string; templateId: string | null; name: string; description: string; mode: string; phase: string | null; gateMode: string | null; prompt: string; toolMode: string | null; scriptName: string | null; enabled: number; defaultOn: number | null; modelProvider: string | null; modelId: string | null; createdAt: string; updatedAt: string; } | undefined; if (byId) { return this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(byId)); } const byTemplate = this.db .prepare("SELECT * FROM workflow_steps WHERE templateId = ? ORDER BY createdAt ASC LIMIT 1") .get(id) as | { id: string; templateId: string | null; name: string; description: string; mode: string; phase: string | null; gateMode: string | null; prompt: string; toolMode: string | null; scriptName: string | null; enabled: number; defaultOn: number | null; modelProvider: string | null; modelId: string | null; createdAt: string; updatedAt: string; } | undefined; if (byTemplate) { return this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(byTemplate)); } const template = this.getBuiltInWorkflowTemplate(id); return template ? this.toBuiltInWorkflowStep(template) : undefined; } /** * Update a workflow step definition. * @throws Error if the workflow step is not found */ async updateWorkflowStep(id: string, updates: Partial): Promise { const row = this.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as | { id: string; templateId: string | null; name: string; description: string; mode: string; phase: string | null; gateMode: string | null; prompt: string; toolMode: string | null; scriptName: string | null; enabled: number; defaultOn: number | null; modelProvider: string | null; modelId: string | null; createdAt: string; updatedAt: string; } | undefined; if (!row) { throw new Error(`Workflow step '${id}' not found`); } const step = this.toStoredWorkflowStep(row); // Handle mode change if (updates.mode !== undefined) { const newMode = updates.mode; // Validate: script mode requires scriptName if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) { throw new Error("Script mode requires a scriptName"); } step.mode = newMode; // When switching to script mode, clear prompt and model overrides if (newMode === "script") { step.prompt = ""; step.gateMode = step.gateMode || "gate"; step.toolMode = undefined; step.modelProvider = undefined; step.modelId = undefined; } // When switching to prompt mode, clear scriptName if (newMode === "prompt") { step.scriptName = undefined; step.gateMode = step.gateMode || "advisory"; step.toolMode = step.toolMode || "readonly"; } } if (updates.name !== undefined) step.name = updates.name; if (updates.description !== undefined) step.description = updates.description; if (updates.phase !== undefined) step.phase = updates.phase; if (updates.gateMode !== undefined) step.gateMode = updates.gateMode; if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt; if (updates.toolMode !== undefined && step.mode === "prompt") step.toolMode = updates.toolMode; if (updates.gateMode !== undefined) step.gateMode = updates.gateMode; if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName; if (updates.enabled !== undefined) step.enabled = updates.enabled; if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn; if (step.mode === "script" && !step.scriptName?.trim()) { throw new Error("Script mode requires a scriptName"); } if (step.mode === "prompt") { if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; if ("modelId" in updates) step.modelId = updates.modelId; } step.updatedAt = new Date().toISOString(); this.db.prepare( `UPDATE workflow_steps SET templateId = ?, name = ?, description = ?, mode = ?, phase = ?, gateMode = ?, prompt = ?, gateMode = ?, toolMode = ?, scriptName = ?, enabled = ?, defaultOn = ?, modelProvider = ?, modelId = ?, updatedAt = ? WHERE id = ?`, ).run( step.templateId ?? null, step.name, step.description, step.mode, step.phase || "pre-merge", step.gateMode, step.prompt, step.gateMode ?? (step.mode === "script" ? "gate" : "advisory"), step.toolMode ?? null, step.scriptName ?? null, step.enabled ? 1 : 0, step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, step.modelProvider ?? null, step.modelId ?? null, step.updatedAt, step.id, ); this.db.bumpLastModified(); this.workflowStepsCache = null; return step; } /** * Delete a workflow step definition. * Also removes the ID from any tasks that reference it in enabledWorkflowSteps. * @throws Error if the workflow step is not found */ async deleteWorkflowStep(id: string): Promise { const deleted = this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(id) as { changes?: number; }; if ((deleted.changes || 0) === 0) { throw new Error(`Workflow step '${id}' not found`); } this.db.bumpLastModified(); this.workflowStepsCache = null; // Clean up references from existing tasks (best-effort, outside config lock) try { const tasks = await this.listTasks({ slim: true }); for (const task of tasks) { if (task.enabledWorkflowSteps?.includes(id)) { const updated = task.enabledWorkflowSteps.filter((wsId) => wsId !== id); // Direct task.json mutation for enabledWorkflowSteps cleanup await this.withTaskLock(task.id, async () => { const dir = this.taskDir(task.id); const t = await this.readTaskJson(dir); t.enabledWorkflowSteps = updated.length > 0 ? updated : undefined; t.updatedAt = new Date().toISOString(); await this.atomicWriteTaskJson(dir, t); }); } } } catch { // Best-effort: task cleanup is non-critical } } /** * Close the database connection and clean up resources. * Call this when the store is no longer needed (e.g., short-lived per-request stores). */ close(): void { this.stopWatching(); // Flush any remaining buffered agent log entries before closing. // Wrap in try-catch because entries for already-deleted tasks will fail FK check. if (this.agentLogBuffer.length > 0) { try { this.flushAgentLogBuffer(); } catch (err) { // Best-effort flush — entries for deleted tasks will fail FK check. // Log the error instead of silently swallowing it. console.warn(`[fusion] Could not flush remaining agent log entries on close:`, err); } } // Cancel any retry timer armed by a failed flush — the DB is about to close. if (this.agentLogFlushTimer) { clearTimeout(this.agentLogFlushTimer); this.agentLogFlushTimer = null; } this.agentLogBuffer.length = 0; if (this._db) { this._db.close(); this._db = null; this.taskIdStateReconciled = false; } if (this._archiveDb) { this._archiveDb.close(); this._archiveDb = null; } } /** * Run a WAL checkpoint and return checkpoint stats. * * The default preserves SQLite's aggressive TRUNCATE behavior for explicit * maintenance/compaction calls. Live engine maintenance should request * PASSIVE explicitly to avoid forcing a blocking truncate on the shared * event loop. */ walCheckpoint(mode?: "PASSIVE" | "TRUNCATE"): { busy: number; log: number; checkpointed: number } { return this.db.walCheckpoint(mode); } getRootDir(): string { return this.rootDir; } /** Return the `.fusion` directory path (e.g. `/project/.fusion`). */ getFusionDir(): string { return this.fusionDir; } getTasksDir(): string { return this.tasksDir; } /** Expose the shared Database instance for co-located stores (e.g. AiSessionStore). */ getDatabase(): Database { return this.db; } getDatabaseHealth(): { healthy: boolean; lastCheckedAt: Date | null; isRunning: boolean; } { return { healthy: !this.db.corruptionDetected, lastCheckedAt: this.db.integrityCheckLastRunAt ? new Date(this.db.integrityCheckLastRunAt) : null, isRunning: this.db.integrityCheckPending, }; } getDistributedTaskIdAllocator(): DistributedTaskIdAllocator { if (!this.distributedTaskIdAllocator) { this.distributedTaskIdAllocator = createDistributedTaskIdAllocator(this.db); } return this.distributedTaskIdAllocator; } /** * Perform a simple database health check. * Returns true if the database responds correctly, false otherwise. * Used for periodic health diagnostics. */ healthCheck(): boolean { try { // Simple query to verify database responsiveness this.db.prepare("SELECT 1").get(); return this.db.checkFts5Integrity(); } catch { return false; } } private generateSpecifiedPrompt(task: Task): string { const deps = task.dependencies.length > 0 ? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n") : "- **None**"; // Get current settings to check for ntfy configuration const settings = this.getSettingsSync(); const notificationsSection = settings.ntfyEnabled && settings.ntfyTopic ? `\n## Notifications\n\nntfy topic: \`${settings.ntfyTopic}\`\n` : ""; const heading = task.title ? `${task.id}: ${task.title}` : task.id; return `# ${heading} **Created:** ${task.createdAt.split("T")[0]} **Size:** M ## Mission ${task.description} ## Dependencies ${deps} ## Steps ### Step 1: Implementation - [ ] Implement the required changes - [ ] Verify changes work correctly ### Step 2: Testing & Verification - [ ] Lint passes - [ ] All tests pass - [ ] Typecheck passes - [ ] No regressions introduced ### Step 3: Documentation & Delivery - [ ] Update relevant documentation ## Acceptance Criteria - [ ] All steps complete - [ ] All tests passing ${notificationsSection}`; } /** * Synchronous version of getSettings for internal use. * Returns project-level settings merged with defaults. * Note: This does NOT merge global settings because it's synchronous * and global settings require async I/O. */ private getSettingsSync(): Settings { try { const row = this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string | null } | undefined; if (!row) return DEFAULT_SETTINGS; const settings = fromJson(row.settings); return { ...DEFAULT_SETTINGS, ...settings }; } catch { return DEFAULT_SETTINGS; } } // ── Activity Log Methods ───────────────────────────────────────── /** * Record an activity log entry to the SQLite database. * Auto-generates ID and timestamp. */ async recordActivity(entry: Omit): Promise { const fullEntry: ActivityLogEntry = { ...entry, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, timestamp: new Date().toISOString(), }; try { this.db.prepare( `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( fullEntry.id, fullEntry.timestamp, fullEntry.type, fullEntry.taskId ?? null, fullEntry.taskTitle ?? null, fullEntry.details, fullEntry.metadata ? JSON.stringify(fullEntry.metadata) : null, ); this.db.bumpLastModified(); } catch (err) { // Best-effort: log errors but don't break operations storeLog.error("Failed to record activity", { id: fullEntry.id, type: fullEntry.type, taskId: fullEntry.taskId, taskTitle: fullEntry.taskTitle, detailsLength: fullEntry.details.length, hasMetadata: fullEntry.metadata !== undefined, error: err instanceof Error ? err.message : String(err), }); } return fullEntry; } /** * Get activity log entries from SQLite. * Returns entries sorted newest first. * Supports filtering by limit, since timestamp, and event type. */ async getActivityLog(options?: { limit?: number; since?: string; type?: ActivityEventType }): Promise { let sql = "SELECT * FROM activityLog WHERE 1=1"; const params: (string | number)[] = []; if (options?.since) { sql += " AND timestamp > ?"; params.push(options.since); } if (options?.type) { sql += " AND type = ?"; params.push(options.type); } sql += " ORDER BY timestamp DESC"; if (options?.limit && options.limit > 0) { sql += " LIMIT ?"; params.push(options.limit); } const rows = this.db.prepare(sql).all(...params) as unknown as ActivityLogRow[]; return rows.map((row) => ({ id: row.id, timestamp: row.timestamp, type: row.type as ActivityEventType, taskId: row.taskId || undefined, taskTitle: row.taskTitle || undefined, details: row.details, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, })); } /** * Clear all activity log entries. * Use with caution - this permanently deletes activity history. */ async clearActivityLog(): Promise { this.db.prepare("DELETE FROM activityLog").run(); this.db.bumpLastModified(); } /** * Get the MissionStore instance for mission hierarchy operations. * Lazily initializes the MissionStore on first access. */ getMissionStore(): MissionStore { if (!this.missionStore) { this.missionStore = new MissionStore(this.fusionDir, this.db, this); } return this.missionStore; } /** * Get the PluginStore instance for plugin registry operations. * Lazily initializes the PluginStore on first access. */ getPluginStore(): PluginStore { if (!this.pluginStore) { // PluginStore persists install/state rows in central DB, so it must use // the same resolved global settings directory as TaskStore. this.pluginStore = new PluginStore(this.rootDir, { centralGlobalDir: this.globalSettingsDir }); } return this.pluginStore; } /** * Get the InsightStore instance for project insights operations. * Lazily initializes the InsightStore on first access. */ getInsightStore(): InsightStore { if (!this.insightStore) { this.insightStore = new InsightStore(this.db); } return this.insightStore; } /** * Get the ResearchStore instance for research run operations. * Lazily initializes the ResearchStore on first access. */ getResearchStore(): ResearchStore { if (!this.researchStore) { this.researchStore = new ResearchStore(this.db); } return this.researchStore; } /** * Get the ExperimentSessionStore instance for upstream-style experiment * session operations (try-measure-keep-revert loop, finalize workflow). * Lazily initializes the ExperimentSessionStore on first access. */ getExperimentSessionStore(): ExperimentSessionStore { if (!this.experimentSessionStore) { this.experimentSessionStore = new ExperimentSessionStore(this.db); } return this.experimentSessionStore; } /** * Get the TodoStore instance for project-scoped todo list operations. * Lazily initializes the TodoStore on first access. */ getTodoStore(): TodoStore { if (!this.todoStore) { this.todoStore = new TodoStore(this.db); } return this.todoStore; } /** * Get the EvalStore instance for eval run and task result operations. * Lazily initializes the EvalStore on first access. */ getEvalStore(): EvalStore { if (!this.evalStore) { this.evalStore = new EvalStore(this.db); } return this.evalStore; } // ── Verification Cache ──────────────────────────────────────────────────── /** * Look up a previously recorded verification cache pass for a given tree sha * and command pair. Returns null when no cached pass exists. * * @param treeSha - The git tree SHA of the merged commit. * @param testCommand - The test command string (normalized to empty string when absent). * @param buildCommand - The build command string (normalized to empty string when absent). */ getVerificationCacheHit( treeSha: string, testCommand: string, buildCommand: string, ): { recordedAt: string; taskId: string | null } | null { const normalizedTest = testCommand ?? ""; const normalizedBuild = buildCommand ?? ""; const row = this.db .prepare( `SELECT recordedAt, taskId FROM verification_cache WHERE treeSha = ? AND testCommand = ? AND buildCommand = ?`, ) .get(treeSha, normalizedTest, normalizedBuild) as | { recordedAt: string; taskId: string | null } | undefined; return row ?? null; } /** * Record a successful verification pass for the given tree sha and commands. * Uses INSERT OR REPLACE so a re-run of the same tree updates the timestamp. * * @param treeSha - The git tree SHA of the merged commit. * @param testCommand - The test command string (normalized to empty string when absent). * @param buildCommand - The build command string (normalized to empty string when absent). * @param taskId - The task ID that triggered the pass (for telemetry). */ recordVerificationCachePass( treeSha: string, testCommand: string, buildCommand: string, taskId: string, ): void { const normalizedTest = testCommand ?? ""; const normalizedBuild = buildCommand ?? ""; const recordedAt = new Date().toISOString(); this.db .prepare( `INSERT OR REPLACE INTO verification_cache (treeSha, testCommand, buildCommand, recordedAt, taskId) VALUES (?, ?, ?, ?, ?)`, ) .run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId); } // ── Shared mesh state export/apply helpers ─────────────────────────────── async getTaskMetadataSnapshot(): Promise { const tasks = await this.listTasks({ slim: false, includeArchived: true }); return createTaskMetadataSnapshot(tasks as unknown as TaskMetadataSnapshot["payload"]["tasks"]); } async applyTaskMetadataSnapshot(snapshot: TaskMetadataSnapshot): Promise<{ applied: number; skipped: number }> { validateSnapshotEnvelope(snapshot); const existingTasks = new Map((await this.listTasks({ slim: false, includeArchived: true })).map((task) => [task.id, task])); let applied = 0; let skipped = 0; for (const incoming of snapshot.payload.tasks) { const current = existingTasks.get(incoming.id); const currentMetadata = current ? toTaskMetadataRecord(current) : undefined; if (currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(incoming)) { skipped++; continue; } const toUpsert: Task = { ...(incoming as unknown as Task), worktree: current?.worktree, executionStartBranch: current?.executionStartBranch, sessionFile: current?.sessionFile, }; this.upsertTaskWithFtsRecovery(toUpsert); applied++; } return { applied, skipped }; } async getActivityLogSnapshot(limit = 10_000): Promise { const entries = await this.getActivityLog({ limit }); return createActivityLogSnapshot([...entries].reverse()); } applyActivityLogSnapshot(snapshot: ActivityLogSnapshot): { applied: number; skipped: number } { validateSnapshotEnvelope(snapshot); let applied = 0; let skipped = 0; for (const entry of snapshot.payload.entries) { const exists = this.db.prepare("SELECT 1 FROM activityLog WHERE id = ?").get(entry.id); if (exists) { skipped++; continue; } this.db.prepare( `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run( entry.id, entry.timestamp, entry.type, entry.taskId ?? null, entry.taskTitle ?? null, entry.details, entry.metadata ? JSON.stringify(entry.metadata) : null, ); applied++; } return { applied, skipped }; } getRunAuditSnapshot(filter: RunAuditEventFilter = {}): RunAuditSnapshot { return createRunAuditSnapshot(this.getRunAuditEvents({ ...filter, limit: filter.limit ?? 10_000 }).reverse()); } applyRunAuditSnapshot(snapshot: RunAuditSnapshot): { applied: number; skipped: number } { validateSnapshotEnvelope(snapshot); let applied = 0; let skipped = 0; for (const entry of snapshot.payload.entries) { const exists = this.db.prepare("SELECT 1 FROM runAuditEvents WHERE id = ?").get(entry.id); if (exists) { skipped++; continue; } this.db.prepare(` INSERT INTO runAuditEvents (id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.id, entry.timestamp, entry.taskId ?? null, entry.agentId, entry.runId, entry.domain, entry.mutationType, entry.target, entry.metadata ? JSON.stringify(entry.metadata) : null, ); applied++; } return { applied, skipped }; } async upsertTaskCommitAssociation( input: Omit & { id?: string }, ): Promise { const now = new Date().toISOString(); const association: TaskCommitAssociation = normalizeTaskCommitAssociation({ id: input.id ?? randomUUID(), createdAt: now, updatedAt: now, ...input, }); this.db.prepare( `INSERT INTO task_commit_associations (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET taskIdSnapshot = excluded.taskIdSnapshot, commitSubject = excluded.commitSubject, authoredAt = excluded.authoredAt, confidence = excluded.confidence, note = excluded.note, updatedAt = excluded.updatedAt`, ).run( association.id, association.taskLineageId, association.taskIdSnapshot, association.commitSha, association.commitSubject, association.authoredAt, association.matchedBy, association.confidence, association.note ?? null, association.createdAt, association.updatedAt, ); return association; } async getTaskCommitAssociationsByLineageId(lineageId: string): Promise { const rows = this.db.prepare( `SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`, ).all(lineageId) as TaskCommitAssociationRow[]; return rows.map((row) => normalizeTaskCommitAssociation({ ...row, note: row.note ?? undefined })); } async replaceLegacyTaskCommitAssociations( lineageId: string, associations: Array>, ): Promise { const deleteStmt = this.db.prepare( `DELETE FROM task_commit_associations WHERE taskLineageId = ? AND matchedBy IN ('legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')`, ); deleteStmt.run(lineageId); for (const association of associations) { await this.upsertTaskCommitAssociation({ ...association, taskLineageId: lineageId }); } } // ── Backward Compatibility (Multi-Project Support) ──────────────────────── }