9211 lines
345 KiB
TypeScript
9211 lines
345 KiB
TypeScript
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, CheckoutClaimPrecondition, 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 { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.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 { SecretsStore } from "./secrets-store.js";
|
|
import { MasterKeyManager } from "./master-key.js";
|
|
import { hasSyncPassphraseConfigured } from "./secrets-sync-passphrase.js";
|
|
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
|
|
import { getInReviewStallReason } from "./in-review-stall.js";
|
|
import { getInReviewStalledSignal } from "./in-review-stalled.js";
|
|
import { getStalePausedReviewSignal } from "./stale-paused-review.js";
|
|
import { getStalePausedTodoSignal } from "./stale-paused-todo.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, summarizeTitle } from "./ai-summarize.js";
|
|
import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
|
import { resolveTitleSummarizerSettingsModel } from "./model-resolution.js";
|
|
import { getErrorMessage } from "./error-message.js";
|
|
import { getTaskCreatedHook } from "./task-creation-hooks.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 { computeRetrySummary } from "./retry-summary.js";
|
|
import { archiveAsSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.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;
|
|
overlapBlockedBy: string | null;
|
|
paused: number | null;
|
|
userPaused: 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;
|
|
worktreeSessionRetryCount: number | null;
|
|
completionHandoffLimboRecoveryCount: number | null;
|
|
verificationFailureCount: number | null;
|
|
mergeConflictBounceCount: number | null;
|
|
mergeAuditBounceCount: number | null;
|
|
branchConflictRecoveryCount: number | null;
|
|
reviewerContextRetryCount: number | null;
|
|
reviewerFallbackRetryCount: 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;
|
|
firstExecutionAt: string | null;
|
|
cumulativeActiveMs: number | 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;
|
|
prInfos: 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;
|
|
noCommitsExpected: 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;
|
|
deletedAt: string | null;
|
|
}
|
|
|
|
/** Database row shape for the task_documents table. */
|
|
const TASK_BRANCH_CONTEXT_METADATA_KEY = "fusionBranchContext";
|
|
|
|
function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record<string, unknown> | 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<string, unknown>;
|
|
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<string, unknown> | undefined,
|
|
branchContext: import("./types.js").TaskBranchContext | undefined,
|
|
): Record<string, unknown> | 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 DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT = 1_000;
|
|
const DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4_000;
|
|
let taskActivityLogEntryLimit = DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT;
|
|
let taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT;
|
|
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<AgentLogEntry["type"]>(["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)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test-only seam for overriding task activity log retention/truncation limits.
|
|
* Must not be used by production code. Tests overriding limits must restore
|
|
* defaults in afterEach/afterAll by passing null.
|
|
*/
|
|
export function __setTaskActivityLogLimitsForTesting(
|
|
overrides: { entryLimit?: number; outcomeLimit?: number } | null,
|
|
): void {
|
|
if (overrides == null || (overrides.entryLimit == null && overrides.outcomeLimit == null)) {
|
|
taskActivityLogEntryLimit = DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT;
|
|
taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT;
|
|
return;
|
|
}
|
|
|
|
if (overrides.entryLimit != null) {
|
|
if (!Number.isInteger(overrides.entryLimit) || overrides.entryLimit < 1) {
|
|
throw new Error("Task activity log entryLimit must be an integer >= 1");
|
|
}
|
|
taskActivityLogEntryLimit = overrides.entryLimit;
|
|
}
|
|
|
|
if (overrides.outcomeLimit != null) {
|
|
if (!Number.isInteger(overrides.outcomeLimit) || overrides.outcomeLimit < 1) {
|
|
throw new Error("Task activity log outcomeLimit must be an integer >= 1");
|
|
}
|
|
taskActivityLogOutcomeLimit = overrides.outcomeLimit;
|
|
}
|
|
}
|
|
|
|
function truncateTaskLogOutcome(outcome: string | undefined): string | undefined {
|
|
if (!outcome || outcome.length <= taskActivityLogOutcomeLimit) {
|
|
return outcome;
|
|
}
|
|
return `${outcome.slice(0, taskActivityLogOutcomeLimit)}\n... outcome truncated to ${taskActivityLogOutcomeLimit} 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(-taskActivityLogEntryLimit);
|
|
return recentEntries.map((entry) => ({
|
|
...entry,
|
|
outcome: truncateTaskLogOutcome(entry.outcome),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Detect whether a PROMPT.md body is the auto-generated bootstrap stub
|
|
* (`# heading\n\n<description>\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;
|
|
|
|
const canonicalWorktrunk = (() => {
|
|
try {
|
|
return validateWorktrunkSettings(base.worktrunk);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
})();
|
|
|
|
const withWorktrunk = {
|
|
...base,
|
|
...(canonicalWorktrunk !== undefined ? { worktrunk: canonicalWorktrunk } : {}),
|
|
};
|
|
|
|
// 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 (withWorktrunk.autoBackupDir === ".kb/backups") {
|
|
return { ...withWorktrunk, autoBackupDir: ".fusion/backups" };
|
|
}
|
|
return withWorktrunk;
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function deepMergeWithNullDelete(
|
|
existingValue: unknown,
|
|
patchValue: Record<string, unknown>,
|
|
): Record<string, unknown> | undefined {
|
|
const merged: Record<string, unknown> = 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; source: "user" | "engine" }];
|
|
"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 const SELF_DEFEATING_OPERATION_VERBS = [
|
|
"finalize", // Terminalize target task state
|
|
"diagnose", // Investigate/diagnose target task failure
|
|
"dispose", // Dispose terminal artifacts/state for target task
|
|
"unblock", // Remove blockers on target task
|
|
"manual recovery", // Explicit manual recovery operation
|
|
"recover", // Recover target task from failed/stuck state
|
|
"recovery", // Recovery operation on target task
|
|
"resolve", // Resolve target task conflict/failure
|
|
"archive", // Archive target task
|
|
"reclaim", // Reclaim target task ownership/artifacts
|
|
"clean", // Clean target task residual state
|
|
"cleanup", // Cleanup operation on target task
|
|
"fix", // Fix target task issue
|
|
] as const satisfies ReadonlyArray<string>;
|
|
|
|
export class SelfDefeatingDependencyError extends Error {
|
|
readonly code = "SELF_DEFEATING_DEPENDENCY" as const;
|
|
|
|
constructor(
|
|
readonly taskTitle: string,
|
|
readonly matchedVerb: string,
|
|
readonly operandTaskId: string,
|
|
) {
|
|
super(`Task "${taskTitle}" operates on ${operandTaskId} (matched verb: "${matchedVerb}") and cannot also depend on it. A task whose job is to mutate another task into a terminal state must not be blocked by that task.`);
|
|
this.name = "SelfDefeatingDependencyError";
|
|
}
|
|
}
|
|
|
|
export function detectSelfDefeatingDependency(
|
|
title: string | undefined,
|
|
dependencies: readonly string[],
|
|
): { matchedVerb: string; operandTaskId: string } | null {
|
|
const trimmedTitle = title?.trim();
|
|
if (!trimmedTitle) return null;
|
|
|
|
const normalizedDeps = new Set(
|
|
dependencies
|
|
.map((dep) => dep.trim().toUpperCase())
|
|
.filter((dep) => /^FN-\d+$/i.test(dep)),
|
|
);
|
|
if (normalizedDeps.size === 0) return null;
|
|
|
|
const titleFnIds = [...trimmedTitle.matchAll(/\bFN-(\d+)\b/gi)];
|
|
if (titleFnIds.length !== 1) return null;
|
|
const operandTaskId = `FN-${titleFnIds[0][1]}`;
|
|
|
|
let matchedVerb: string | null = null;
|
|
for (const verb of SELF_DEFEATING_OPERATION_VERBS) {
|
|
if (verb === "manual recovery") {
|
|
if (/\bmanual\s+recovery\b/i.test(trimmedTitle)) {
|
|
matchedVerb = verb;
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const escapedVerb = verb.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
if (new RegExp(`\\b${escapedVerb}\\b`, "i").test(trimmedTitle)) {
|
|
matchedVerb = verb;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchedVerb) return null;
|
|
if (!normalizedDeps.has(operandTaskId.toUpperCase())) return null;
|
|
|
|
return {
|
|
matchedVerb,
|
|
operandTaskId,
|
|
};
|
|
}
|
|
|
|
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
|
|
|
|
static async getOrCreateForProject(
|
|
projectId?: string,
|
|
centralCore?: CentralCore,
|
|
globalSettingsDir?: string,
|
|
): Promise<TaskStore> {
|
|
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<string, Task> = new Map();
|
|
/** Paths recently written by in-process mutations (suppresses duplicate events) */
|
|
private recentlyWritten: Set<string> = new Set();
|
|
/** Pending debounce timers keyed by task ID */
|
|
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
/** Debounce interval in ms */
|
|
private debounceMs = 150;
|
|
/** Per-task promise chain for serializing writes */
|
|
private taskLocks: Map<string, Promise<void>> = 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<void> = Promise.resolve();
|
|
/** Promise chain for serializing config.json read-modify-write cycles */
|
|
private configLock: Promise<void> = 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<typeof setInterval> | 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<string, { expiresAt: number; promise: Promise<Task[]> }>();
|
|
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 SecretsStore instance */
|
|
private secretsStore: SecretsStore | null = null;
|
|
/** Cached central connection for SecretsStore global scope access */
|
|
private secretsCentralCore: CentralCore | 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<typeof setTimeout> | 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<string>();
|
|
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<void> {
|
|
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,
|
|
overlapBlockedBy: row.overlapBlockedBy || undefined,
|
|
paused: row.paused ? true : undefined,
|
|
userPaused: row.userPaused ? 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,
|
|
worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined,
|
|
completionHandoffLimboRecoveryCount: row.completionHandoffLimboRecoveryCount ?? undefined,
|
|
verificationFailureCount: row.verificationFailureCount ?? undefined,
|
|
mergeConflictBounceCount: row.mergeConflictBounceCount ?? undefined,
|
|
mergeAuditBounceCount: row.mergeAuditBounceCount ?? undefined,
|
|
branchConflictRecoveryCount: row.branchConflictRecoveryCount ?? undefined,
|
|
reviewerContextRetryCount: row.reviewerContextRetryCount ?? undefined,
|
|
reviewerFallbackRetryCount: row.reviewerFallbackRetryCount ?? 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,
|
|
firstExecutionAt: row.firstExecutionAt || undefined,
|
|
cumulativeActiveMs: row.cumulativeActiveMs ?? undefined,
|
|
executionStartedAt: row.executionStartedAt || undefined,
|
|
executionCompletedAt: row.executionCompletedAt || undefined,
|
|
dependencies: fromJson<string[]>(row.dependencies) || [],
|
|
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
|
|
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
|
|
tokenBudgetSoftAlertedAt: row.tokenBudgetSoftAlertedAt || undefined,
|
|
tokenBudgetHardAlertedAt: row.tokenBudgetHardAlertedAt || undefined,
|
|
tokenBudgetOverride: fromJson<import("./types.js").TaskTokenBudgetOverride>(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<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
|
|
steeringComments: (() => {
|
|
const sc = fromJson<import("./types.js").SteeringComment[]>(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<import("./types.js").TaskComment[]>(row.comments) || [];
|
|
// Deduplicate by id to recover from prior corruption
|
|
const seen = new Set<string>();
|
|
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<import("./types.js").TaskReview>(row.review) ?? undefined,
|
|
reviewState: normalizeTaskReviewState(fromJson<import("./types.js").TaskReviewState>(row.reviewState) ?? undefined),
|
|
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
|
|
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
|
|
prInfos: (() => {
|
|
const multi = fromJson<import("./types.js").PrInfo[]>(row.prInfos);
|
|
if (multi && multi.length > 0) return multi;
|
|
const single = fromJson<import("./types.js").PrInfo>(row.prInfo);
|
|
return single ? [single] : undefined;
|
|
})(),
|
|
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
|
|
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(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<import("./types.js").MergeDetails>(row.mergeDetails),
|
|
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
|
|
noCommitsExpected: row.noCommitsExpected ? true : undefined,
|
|
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
|
|
modifiedFiles: (() => { const m = fromJson<string[]>(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<Record<string, unknown>>(row.sourceMetadata) ?? undefined;
|
|
return withTaskBranchContextInSourceMetadata(parsed, parseTaskBranchContextFromSourceMetadata(parsed));
|
|
})(),
|
|
branchContext: (() => {
|
|
const parsed = fromJson<Record<string, unknown>>(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,
|
|
deletedAt: row.deletedAt ?? 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,
|
|
prInfos: slim ? undefined : entry.prInfos,
|
|
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,
|
|
firstExecutionAt: entry.firstExecutionAt,
|
|
cumulativeActiveMs: entry.cumulativeActiveMs,
|
|
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,
|
|
noCommitsExpected: entry.noCommitsExpected,
|
|
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<string, number>();
|
|
const countsByAgent = new Map<string, number>();
|
|
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<string | undefined> {
|
|
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<Pick<ArchivedTaskEntry, "agentLogMode" | "agentLogSummary" | "agentLogSnapshot" | "agentLogFull">> {
|
|
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<ArchivedTaskEntry> {
|
|
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,
|
|
prInfos: task.prInfos,
|
|
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,
|
|
firstExecutionAt: task.firstExecutionAt,
|
|
cumulativeActiveMs: task.cumulativeActiveMs,
|
|
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,
|
|
noCommitsExpected: task.noCommitsExpected,
|
|
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<Record<string, unknown>>(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<Record<string, unknown>>(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", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
|
"modelPresetId", "modelProvider", "modelId",
|
|
"validatorModelProvider", "validatorModelId",
|
|
"planningModelProvider", "planningModelId",
|
|
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
|
|
"error", "summary", "thinkingLevel", "executionMode",
|
|
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
|
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
|
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
|
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
|
"breakIntoSubtasks", "noCommitsExpected", "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", "deletedAt",
|
|
// `log` is fetched in slim mode so the server can aggregate
|
|
// `timedExecutionMs` from `[timing] … in <N>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 <N>ms` (or `… after <N>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", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
|
"modelPresetId", "modelProvider", "modelId",
|
|
"validatorModelProvider", "validatorModelId",
|
|
"planningModelProvider", "planningModelId",
|
|
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
|
|
"error", "summary", "thinkingLevel", "executionMode",
|
|
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
|
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
|
"dependencies", "steps", "attachments", "steeringComments",
|
|
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
|
"breakIntoSubtasks", "noCommitsExpected", "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", "deletedAt",
|
|
];
|
|
|
|
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.overlapBlockedBy ?? null,
|
|
task.paused ? 1 : 0,
|
|
task.userPaused ? 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.worktreeSessionRetryCount ?? 0,
|
|
task.completionHandoffLimboRecoveryCount ?? 0,
|
|
task.verificationFailureCount ?? 0,
|
|
task.mergeConflictBounceCount ?? 0,
|
|
task.mergeAuditBounceCount ?? 0,
|
|
task.branchConflictRecoveryCount ?? 0,
|
|
task.reviewerContextRetryCount ?? 0,
|
|
task.reviewerFallbackRetryCount ?? 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.firstExecutionAt ?? null,
|
|
task.cumulativeActiveMs ?? 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),
|
|
toJson(task.prInfos || []),
|
|
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,
|
|
task.noCommitsExpected ? 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,
|
|
task.deletedAt ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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, overlapBlockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
|
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
|
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
|
|
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
|
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
|
|
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
|
|
dependencies, steps, log, attachments, steeringComments,
|
|
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
|
mergeDetails, breakIntoSubtasks, noCommitsExpected, 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, deletedAt
|
|
) 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.
|
|
* FN-4898: this low-level persistence path intentionally does not normalize
|
|
* titles because replication/restore flows may carry authoritative bytes.
|
|
*/
|
|
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, overlapBlockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
|
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
|
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
|
|
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
|
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
|
|
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
|
|
dependencies, steps, log, attachments, steeringComments,
|
|
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
|
mergeDetails, breakIntoSubtasks, noCommitsExpected, 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, deletedAt
|
|
) 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,
|
|
overlapBlockedBy = excluded.overlapBlockedBy,
|
|
paused = excluded.paused,
|
|
userPaused = excluded.userPaused,
|
|
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,
|
|
worktreeSessionRetryCount = excluded.worktreeSessionRetryCount,
|
|
completionHandoffLimboRecoveryCount = excluded.completionHandoffLimboRecoveryCount,
|
|
verificationFailureCount = excluded.verificationFailureCount,
|
|
mergeConflictBounceCount = excluded.mergeConflictBounceCount,
|
|
mergeAuditBounceCount = excluded.mergeAuditBounceCount,
|
|
branchConflictRecoveryCount = excluded.branchConflictRecoveryCount,
|
|
reviewerContextRetryCount = excluded.reviewerContextRetryCount,
|
|
reviewerFallbackRetryCount = excluded.reviewerFallbackRetryCount,
|
|
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,
|
|
firstExecutionAt = excluded.firstExecutionAt,
|
|
cumulativeActiveMs = excluded.cumulativeActiveMs,
|
|
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,
|
|
prInfos = excluded.prInfos,
|
|
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,
|
|
noCommitsExpected = excluded.noCommitsExpected,
|
|
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,
|
|
deletedAt = excluded.deletedAt
|
|
`).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; includeDeleted?: boolean }): Task | undefined {
|
|
const selectClause = options?.activityLogLimit
|
|
? this.getTaskSelectClauseWithActivityLogLimit(options.activityLogLimit)
|
|
: "*";
|
|
const whereClause = options?.includeDeleted ? "id = ?" : `id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`;
|
|
const row = this.db.prepare(`SELECT ${selectClause} FROM tasks WHERE ${whereClause}`).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 {
|
|
// FN-5105: include soft-deleted rows so IDs remain permanently reserved.
|
|
if (this.readTaskFromDb(id, { includeDeleted: true })) {
|
|
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 = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).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 != ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`)
|
|
.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<ActivityLogEntry, "id" | "timestamp">,
|
|
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<T>(fn: () => Promise<T>): Promise<T> {
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((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<T>(fn: () => Promise<T>): Promise<T> {
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((r) => { resolve = r; });
|
|
const prev = this.worktreeAllocationLock;
|
|
this.worktreeAllocationLock = next;
|
|
|
|
return prev.then(async () => {
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
resolve!();
|
|
}
|
|
});
|
|
}
|
|
|
|
private withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = this.taskLocks.get(id) ?? Promise.resolve();
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((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<Task> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Settings> {
|
|
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)),
|
|
);
|
|
const merged = {
|
|
...DEFAULT_SETTINGS,
|
|
...globalSettings,
|
|
...projectSettings,
|
|
worktrunk: resolveWorktrunkSettings(
|
|
globalSettings.worktrunk,
|
|
(projectSettings as Partial<Settings>).worktrunk,
|
|
),
|
|
};
|
|
try {
|
|
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
|
|
} catch {
|
|
merged.secretsSyncPassphraseConfigured = false;
|
|
}
|
|
return canonicalizeSettings(merged);
|
|
}
|
|
|
|
/**
|
|
* 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<Settings> {
|
|
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<Settings>(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<Settings> | undefined = raw
|
|
? (Object.fromEntries(
|
|
Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)),
|
|
) as Partial<Settings>)
|
|
: undefined;
|
|
|
|
const merged = {
|
|
...DEFAULT_SETTINGS,
|
|
...globalSettings,
|
|
...projectSettings,
|
|
worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk),
|
|
};
|
|
try {
|
|
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
|
|
} catch {
|
|
merged.secretsSyncPassphraseConfigured = false;
|
|
}
|
|
|
|
return canonicalizeSettings(merged);
|
|
}
|
|
|
|
/**
|
|
* 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<ProjectSettings> }> {
|
|
const [globalSettings, config] = await Promise.all([
|
|
this.globalSettingsStore.getSettings(),
|
|
this.readConfig(),
|
|
]);
|
|
try {
|
|
globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
|
|
} catch {
|
|
globalSettings.secretsSyncPassphraseConfigured = false;
|
|
}
|
|
|
|
// Extract only project-level keys from config.settings
|
|
const projectSettings: Partial<ProjectSettings> = {};
|
|
if (config.settings) {
|
|
for (const key of Object.keys(config.settings)) {
|
|
if (!isGlobalOnlySettingsKey(key)) {
|
|
(projectSettings as Record<string, unknown>)[key] = (config.settings as Record<string, unknown>)[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<ProjectSettings> }> {
|
|
const [globalSettings, row] = await Promise.all([
|
|
this.globalSettingsStore.getSettings(),
|
|
this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined,
|
|
]);
|
|
try {
|
|
globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
|
|
} catch {
|
|
globalSettings.secretsSyncPassphraseConfigured = false;
|
|
}
|
|
|
|
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
|
|
|
|
// Extract only project-level keys from config.settings
|
|
const projectScoped: Partial<ProjectSettings> = {};
|
|
if (projectSettings) {
|
|
for (const key of Object.keys(projectSettings)) {
|
|
if (!isGlobalOnlySettingsKey(key)) {
|
|
(projectScoped as Record<string, unknown>)[key] = (projectSettings as Record<string, unknown>)[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<Settings>` 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<Settings>): Promise<Settings> {
|
|
// Filter out global-only fields — they should go through updateGlobalSettings()
|
|
const projectPatch: Partial<Settings> = {};
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (!isGlobalOnlySettingsKey(key)) {
|
|
(projectPatch as Record<string, unknown>)[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<string, unknown>)["promptOverrides"];
|
|
if (incomingPromptOverrides === null) {
|
|
// promptOverrides: null → clear the entire promptOverrides object
|
|
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
|
|
delete (projectPatch as Record<string, unknown>)["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<string, unknown>;
|
|
const existingMap = ((config.settings as unknown as Record<string, unknown>)["promptOverrides"] as Record<string, string>) ?? {};
|
|
const mergedMap: Record<string, string> = { ...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<string, unknown>)["promptOverrides"];
|
|
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
|
|
} else {
|
|
(config.settings as unknown as Record<string, unknown>)["promptOverrides"] = mergedMap;
|
|
(projectPatch as Record<string, unknown>)["promptOverrides"] = mergedMap;
|
|
}
|
|
}
|
|
|
|
// Handle null values for other top-level keys (non-promptOverrides)
|
|
for (const key of Object.keys(projectPatch)) {
|
|
if ((projectPatch as Record<string, unknown>)[key] === null) {
|
|
delete (config.settings as unknown as Record<string, unknown>)[key];
|
|
delete (projectPatch as Record<string, unknown>)[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<GlobalSettings>): Promise<Settings> {
|
|
// 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<GlobalSettings> = { ...patch };
|
|
delete globalPatch.secretsSyncPassphraseConfigured;
|
|
|
|
// Handle deep merge + targeted null clear semantics for remoteAccess
|
|
const incomingRemoteAccess = (globalPatch as Record<string, unknown>)["remoteAccess"];
|
|
if (incomingRemoteAccess === null) {
|
|
(globalPatch as Record<string, unknown>)["remoteAccess"] = null;
|
|
} else if (isPlainObject(incomingRemoteAccess)) {
|
|
const existingRemoteAccess = (previousGlobal as Record<string, unknown>)["remoteAccess"];
|
|
const mergedRemoteAccess = deepMergeWithNullDelete(existingRemoteAccess, incomingRemoteAccess);
|
|
|
|
if (mergedRemoteAccess === undefined) {
|
|
(globalPatch as Record<string, unknown>)["remoteAccess"] = null;
|
|
} else {
|
|
(globalPatch as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
|
|
}
|
|
}
|
|
|
|
// Handle experimentalFeatures merging (similar to promptOverrides)
|
|
const incomingExperimentalFeatures = (globalPatch as Record<string, unknown>)["experimentalFeatures"];
|
|
if (incomingExperimentalFeatures === null) {
|
|
(globalPatch as Record<string, unknown>)["experimentalFeatures"] = null;
|
|
} else if (
|
|
incomingExperimentalFeatures !== undefined &&
|
|
typeof incomingExperimentalFeatures === "object" &&
|
|
!Array.isArray(incomingExperimentalFeatures)
|
|
) {
|
|
const incomingMap = incomingExperimentalFeatures as Record<string, unknown>;
|
|
const existingMap = ((previousGlobal as Record<string, unknown>)["experimentalFeatures"] as Record<string, boolean>) ?? {};
|
|
const mergedMap: Record<string, boolean> = { ...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<string, unknown>)["experimentalFeatures"] = mergedMap;
|
|
}
|
|
|
|
const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch);
|
|
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
|
try {
|
|
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
|
|
} catch {
|
|
merged.secretsSyncPassphraseConfigured = false;
|
|
}
|
|
|
|
// 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<BoardConfig> {
|
|
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<Settings>(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<Settings>(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<void> {
|
|
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<string> {
|
|
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<string | null>;
|
|
settings?: { autoSummarizeTitles?: boolean };
|
|
createTaskWithId?: (taskId: string) => Promise<Task>;
|
|
},
|
|
): Promise<Task> {
|
|
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
|
|
: "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<string, unknown> | undefined {
|
|
const row = this.db
|
|
.prepare("SELECT workflowSteps FROM config WHERE id = 1")
|
|
.get() as { workflowSteps?: string | null } | undefined;
|
|
const legacySteps = fromJson<Array<Record<string, unknown>>>(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 = "advisory";
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private async ensureWorkflowStepForTemplate(templateId: string): Promise<import("./types.js").WorkflowStep> {
|
|
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<string[] | undefined> {
|
|
if (!stepIds?.length) return undefined;
|
|
|
|
const resolved: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
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<string | null>;
|
|
settings?: { autoSummarizeTitles?: boolean };
|
|
invokeTaskCreatedHook?: boolean;
|
|
}
|
|
): Promise<Task> {
|
|
if (!input.description?.trim()) {
|
|
throw new Error("Description is required and cannot be empty");
|
|
}
|
|
|
|
const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []);
|
|
if (selfDefeatingDep) {
|
|
throw new SelfDefeatingDependencyError(
|
|
input.title?.trim() ?? "",
|
|
selfDefeatingDep.matchedVerb,
|
|
selfDefeatingDep.operandTaskId,
|
|
);
|
|
}
|
|
|
|
let resolvedSettings = options?.settings;
|
|
if (!resolvedSettings) {
|
|
try {
|
|
resolvedSettings = await this.getSettings();
|
|
} catch {
|
|
resolvedSettings = {};
|
|
}
|
|
}
|
|
|
|
let onSummarize = options?.onSummarize;
|
|
if (!onSummarize && resolvedSettings?.autoSummarizeTitles === true) {
|
|
const summarizerModel = resolveTitleSummarizerSettingsModel(resolvedSettings);
|
|
if (summarizerModel.provider && summarizerModel.modelId) {
|
|
onSummarize = async (description: string) => {
|
|
try {
|
|
return await summarizeTitle(
|
|
description,
|
|
this.getRootDir(),
|
|
summarizerModel.provider,
|
|
summarizerModel.modelId,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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 || resolvedSettings?.autoSummarizeTitles === true);
|
|
const hasPendingSummarization = shouldSummarize && typeof onSummarize === "function";
|
|
const shouldInvokeTaskCreatedHook = options?.invokeTaskCreatedHook !== false;
|
|
|
|
// 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,
|
|
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization },
|
|
);
|
|
},
|
|
});
|
|
|
|
if (hasPendingSummarization && shouldInvokeTaskCreatedHook) {
|
|
const id = task.id;
|
|
Promise.resolve().then(async () => {
|
|
try {
|
|
const generatedTitle = await onSummarize!(input.description);
|
|
const sanitizedTitle = sanitizeTitle(generatedTitle);
|
|
if (sanitizedTitle) {
|
|
const currentTask = this.readTaskFromDb(id);
|
|
if (currentTask && !currentTask.title) {
|
|
// FN-5077: normalizeTitleForTaskId may return null for dangling fragments; only persist usable titles.
|
|
const normalizedTitle = normalizeTitleForTaskId(sanitizedTitle, id);
|
|
if (normalizedTitle.title) {
|
|
await this.updateTask(id, { title: normalizedTitle.title });
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const autoEnabled = resolvedSettings?.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,
|
|
},
|
|
);
|
|
}
|
|
|
|
let latestTask = task;
|
|
try {
|
|
const refreshed = this.readTaskFromDb(id);
|
|
if (refreshed) latestTask = refreshed;
|
|
} catch {
|
|
// Best-effort refresh; fall back to original task snapshot.
|
|
}
|
|
|
|
try {
|
|
await this.invokeTaskCreatedHook(latestTask);
|
|
} catch (err) {
|
|
storeLog.warn("Deferred task-created hook failed", {
|
|
taskId: id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}).catch((err) => {
|
|
const autoEnabled = resolvedSettings?.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;
|
|
invokeTaskCreatedHook?: boolean;
|
|
},
|
|
): Promise<Task> {
|
|
if (!input.description?.trim()) {
|
|
throw new Error("Description is required and cannot be empty");
|
|
}
|
|
|
|
const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []);
|
|
if (selfDefeatingDep) {
|
|
throw new SelfDefeatingDependencyError(
|
|
input.title?.trim() ?? "",
|
|
selfDefeatingDep.matchedVerb,
|
|
selfDefeatingDep.operandTaskId,
|
|
);
|
|
}
|
|
|
|
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,
|
|
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
|
|
});
|
|
}
|
|
|
|
async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise<MeshReplicatedTaskApplyResult> {
|
|
// Intentionally does not invoke the post-create hook. Replicated tasks mirror
|
|
// state from an origin node; rerunning side effects here (e.g. GitHub issue
|
|
// creation) would duplicate external artifacts.
|
|
// FN-4898: replicated creates route via _createTaskInternal so drift normalization
|
|
// is applied exactly once (same behavior as user-originated writes).
|
|
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,
|
|
invokeTaskCreatedHook: 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;
|
|
invokeTaskCreatedHook?: boolean;
|
|
},
|
|
): Promise<Task> {
|
|
const now = options?.createdAt ?? new Date().toISOString();
|
|
// FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior.
|
|
const normalizedTitle = normalizeTitleForTaskId(title, id);
|
|
const task: Task = {
|
|
id,
|
|
lineageId: input.lineageId ?? generateTaskLineageId(),
|
|
title: normalizedTitle.title ?? undefined,
|
|
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,
|
|
noCommitsExpected: input.noCommitsExpected === 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,
|
|
};
|
|
|
|
if (normalizedTitle.changed) {
|
|
task.log.push({
|
|
timestamp: now,
|
|
action: "Title normalized: stripped legacy task-id reference",
|
|
});
|
|
const removed = extractTaskIdTokens(title ?? "").filter((token) => token !== id.toUpperCase());
|
|
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
|
}
|
|
|
|
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);
|
|
|
|
await this._maybeAutoArchiveSameAgentDuplicate(task, input);
|
|
|
|
this.emit("task:created", task);
|
|
if (options?.invokeTaskCreatedHook !== false) {
|
|
await this.invokeTaskCreatedHook(task);
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private async _maybeAutoArchiveSameAgentDuplicate(task: Task, input: TaskCreateInput): Promise<void> {
|
|
const sourceAgentId = task.sourceAgentId ?? null;
|
|
if (!sourceAgentId) return;
|
|
|
|
try {
|
|
const nowMs = Date.now();
|
|
const recent = (await this.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
|
|
if (candidate.id === task.id) return false;
|
|
if (candidate.sourceAgentId !== sourceAgentId) return false;
|
|
const createdMs = Date.parse(candidate.createdAt);
|
|
if (Number.isNaN(createdMs)) return false;
|
|
return createdMs >= nowMs - 24 * 60 * 60 * 1000;
|
|
});
|
|
|
|
const matches = findSameAgentDuplicates(
|
|
{ title: input.title ?? task.title, description: input.description },
|
|
recent.map((candidate) => ({
|
|
id: candidate.id,
|
|
title: candidate.title ?? "",
|
|
description: candidate.description,
|
|
column: candidate.column,
|
|
createdAt: Date.parse(candidate.createdAt),
|
|
sourceAgentId: candidate.sourceAgentId ?? null,
|
|
})),
|
|
{ nowMs },
|
|
);
|
|
|
|
if (matches.length === 0) return;
|
|
|
|
const siblingTaskIds = matches.map((match) => match.id);
|
|
const scores = Object.fromEntries(matches.map((match) => [match.id, match.score]));
|
|
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
|
|
task.column = "archived";
|
|
} catch (error) {
|
|
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
private async invokeTaskCreatedHook(task: Task): Promise<void> {
|
|
const taskCreatedHook = getTaskCreatedHook();
|
|
if (!taskCreatedHook) return;
|
|
try {
|
|
await taskCreatedHook(task, this);
|
|
} catch (error) {
|
|
storeLog.warn(`[task-created-hook] ${task.id}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<Task> {
|
|
const sourceTask = await this.getTask(id);
|
|
const now = new Date().toISOString();
|
|
|
|
return this.createTaskWithDistributedReservation({ description: sourceTask.description }, {
|
|
createTaskWithId: async (newId) => {
|
|
// FN-5077: duplicated drift-stripped fragments may normalize to null and should remain unset.
|
|
const normalizedTitle = normalizeTitleForTaskId(sourceTask.title, newId);
|
|
if (normalizedTitle.changed) {
|
|
const removed = extractTaskIdTokens(sourceTask.title ?? "").filter((token) => token !== newId.toUpperCase());
|
|
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
|
|
}
|
|
const newTask: Task = {
|
|
id: newId,
|
|
lineageId: generateTaskLineageId(),
|
|
title: normalizedTitle.title ?? undefined,
|
|
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);
|
|
await this.invokeTaskCreatedHook(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<Task> {
|
|
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) => {
|
|
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
|
|
const normalizedTitle = normalizeTitleForTaskId(`Refinement: ${sourceLabel}`, newId);
|
|
if (normalizedTitle.changed) {
|
|
const removed = extractTaskIdTokens(`Refinement: ${sourceLabel}`).filter((token) => token !== newId.toUpperCase());
|
|
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
|
|
}
|
|
const newTask: Task = {
|
|
id: newId,
|
|
lineageId: generateTaskLineageId(),
|
|
title: normalizedTitle.title ?? "Refinement",
|
|
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);
|
|
await this.invokeTaskCreatedHook(newTask);
|
|
return newTask;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read a task and its prompt content.
|
|
*/
|
|
async getTask(id: string, options?: { activityLogLimit?: number; includeDeleted?: boolean }): Promise<TaskDetail> {
|
|
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() });
|
|
// Derived at read time only; retrySummary is never persisted to SQLite.
|
|
task.retrySummary = computeRetrySummary(task);
|
|
|
|
// 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 getTaskColumns(ids: string[]): Promise<Map<string, Column>> {
|
|
if (ids.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const uniqueIds = [...new Set(ids)];
|
|
const placeholders = uniqueIds.map(() => "?").join(",");
|
|
const rows = this.db
|
|
.prepare(`SELECT id, "column" FROM tasks WHERE id IN (${placeholders}) AND ${TaskStore.ACTIVE_TASKS_WHERE}`)
|
|
.all(...uniqueIds) as Array<{ id: string; column: Column }>;
|
|
|
|
const activeById = new Map<string, Column>();
|
|
for (const row of rows) {
|
|
activeById.set(row.id, row.column);
|
|
}
|
|
|
|
const missingIds: string[] = [];
|
|
for (const id of uniqueIds) {
|
|
if (!activeById.has(id)) {
|
|
missingIds.push(id);
|
|
}
|
|
}
|
|
|
|
const archivedSet = missingIds.length > 0 ? this.archiveDb.filterArchived(missingIds) : new Set<string>();
|
|
|
|
const result = new Map<string, Column>();
|
|
for (const id of uniqueIds) {
|
|
const activeColumn = activeById.get(id);
|
|
if (activeColumn !== undefined) {
|
|
result.set(id, activeColumn);
|
|
} else if (archivedSet.has(id)) {
|
|
result.set(id, "archived");
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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<Task[]> {
|
|
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[] = [];
|
|
whereParts.push(TaskStore.ACTIVE_TASKS_WHERE);
|
|
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, autoMerge: settings.autoMerge });
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: settings.autoMerge,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
});
|
|
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 });
|
|
// Derived at read time only; retrySummary is never persisted to SQLite.
|
|
task.retrySummary = computeRetrySummary(task);
|
|
|
|
// 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));
|
|
}
|
|
|
|
async listStrandedRefinements(options?: {
|
|
freshnessThresholdMs?: number;
|
|
}): Promise<Array<{
|
|
task: Task;
|
|
reasons: Array<"untriaged-stale" | "awaiting-approval" | "failed" | "stuck-killed" | "recovery-backoff">;
|
|
nextRecoveryAt?: string;
|
|
ageMs: number;
|
|
}>> {
|
|
const defaultFreshnessThresholdMs = 10 * 60 * 1000;
|
|
const requestedThresholdMs = options?.freshnessThresholdMs;
|
|
const freshnessThresholdMs = Number.isFinite(requestedThresholdMs) && (requestedThresholdMs ?? 0) >= 0
|
|
? requestedThresholdMs as number
|
|
: defaultFreshnessThresholdMs;
|
|
|
|
const selectClause = this.getTaskSelectClause(false);
|
|
const rows = this.db.prepare(
|
|
`SELECT ${selectClause} FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND "sourceType" = 'task_refine' AND "column" = 'triage' ORDER BY createdAt ASC`,
|
|
).all() as unknown as TaskRow[];
|
|
|
|
const now = Date.now();
|
|
const stranded: Array<{
|
|
task: Task;
|
|
reasons: Array<"untriaged-stale" | "awaiting-approval" | "failed" | "stuck-killed" | "recovery-backoff">;
|
|
nextRecoveryAt?: string;
|
|
ageMs: number;
|
|
}> = [];
|
|
|
|
for (const row of rows) {
|
|
const task = this.rowToTask(row);
|
|
if (task.paused) {
|
|
continue;
|
|
}
|
|
|
|
const reasons: Array<"untriaged-stale" | "awaiting-approval" | "failed" | "stuck-killed" | "recovery-backoff"> = [];
|
|
const createdAtMs = Date.parse(task.createdAt);
|
|
const ageMs = Number.isFinite(createdAtMs) ? Math.max(0, now - createdAtMs) : 0;
|
|
|
|
if (task.status === undefined && ageMs > freshnessThresholdMs) {
|
|
reasons.push("untriaged-stale");
|
|
}
|
|
if (task.status === "awaiting-approval") {
|
|
reasons.push("awaiting-approval");
|
|
}
|
|
if (task.status === "failed") {
|
|
reasons.push("failed");
|
|
}
|
|
if (task.status === "stuck-killed") {
|
|
reasons.push("stuck-killed");
|
|
}
|
|
if (task.nextRecoveryAt) {
|
|
const nextRecoveryAtMs = Date.parse(task.nextRecoveryAt);
|
|
if (Number.isFinite(nextRecoveryAtMs) && nextRecoveryAtMs > now) {
|
|
reasons.push("recovery-backoff");
|
|
}
|
|
}
|
|
|
|
if (reasons.length > 0) {
|
|
stranded.push({
|
|
task,
|
|
reasons,
|
|
nextRecoveryAt: task.nextRecoveryAt,
|
|
ageMs,
|
|
});
|
|
}
|
|
}
|
|
|
|
return stranded;
|
|
}
|
|
|
|
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 ${TaskStore.ACTIVE_TASKS_WHERE} AND updatedAt > ? ORDER BY updatedAt ASC LIMIT ?`,
|
|
).all(since, resolvedLimit + 1) as TaskRow[])
|
|
: (this.db.prepare(
|
|
`SELECT ${selectClause} FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND 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, autoMerge: settings.autoMerge });
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: settings.autoMerge,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
});
|
|
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 });
|
|
// Derived at read time only; retrySummary is never persisted to SQLite.
|
|
task.retrySummary = computeRetrySummary(task);
|
|
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 ${TaskStore.ACTIVE_TASKS_WHERE} AND status IN ('merging', 'merging-pr') AND id != ? LIMIT 1`
|
|
: `SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND 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<Task[]> {
|
|
// 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'`} AND t."deletedAt" IS NULL`;
|
|
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'`} AND t."deletedAt" IS NULL`;
|
|
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, autoMerge: settings.autoMerge });
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: settings.autoMerge,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
});
|
|
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 findRecentTasksByContentFingerprint(
|
|
fingerprint: string,
|
|
options?: { windowMs?: number; includeArchived?: boolean },
|
|
): Promise<Task[]> {
|
|
const trimmedFingerprint = fingerprint.trim();
|
|
if (trimmedFingerprint.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const requestedWindowMs = options?.windowMs ?? 60_000;
|
|
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(requestedWindowMs)));
|
|
const cutoffIso = new Date(Date.now() - windowMs).toISOString();
|
|
const includeArchived = options?.includeArchived ?? false;
|
|
const selectClause = this.getTaskSelectClause(false, "t");
|
|
|
|
const rows = this.db.prepare(`
|
|
SELECT ${selectClause}
|
|
FROM tasks t
|
|
WHERE t."deletedAt" IS NULL
|
|
AND json_extract(t.sourceMetadata, '$.contentFingerprint') = ?
|
|
AND t.createdAt >= ?
|
|
${includeArchived ? "" : "AND t.\"column\" != 'archived'"}
|
|
ORDER BY t.createdAt ASC
|
|
`).all(trimmedFingerprint, cutoffIso) as TaskRow[];
|
|
|
|
return rows.map((row) => this.rowToTask(row));
|
|
}
|
|
|
|
async getTasksByAssignedAgent(
|
|
agentId: string,
|
|
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
|
|
): Promise<Task[]> {
|
|
const whereClauses = ["assignedAgentId = ?", TaskStore.ACTIVE_TASKS_WHERE];
|
|
const params: Array<string | number> = [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 tryClaimCheckout(
|
|
taskId: string,
|
|
claim: {
|
|
agentId: string;
|
|
nodeId: string;
|
|
runId: string | null;
|
|
leaseEpoch: number;
|
|
renewedAt: string;
|
|
},
|
|
precondition: CheckoutClaimPrecondition,
|
|
): Promise<{ ok: true; task: Task } | { ok: false; reason: "row_not_found" | "precondition_failed"; current: Task | null }> {
|
|
const current = await this.getTask(taskId);
|
|
if (!current) {
|
|
return { ok: false, reason: "row_not_found", current: null };
|
|
}
|
|
|
|
const updateResult = this.db.prepare(`
|
|
UPDATE tasks
|
|
SET
|
|
checkedOutBy = ?,
|
|
checkedOutAt = COALESCE(checkedOutAt, ?),
|
|
checkoutNodeId = ?,
|
|
checkoutRunId = ?,
|
|
checkoutLeaseRenewedAt = ?,
|
|
checkoutLeaseEpoch = ?
|
|
WHERE id = ?
|
|
AND "deletedAt" IS NULL
|
|
AND COALESCE(checkedOutBy, '') = COALESCE(?, '')
|
|
AND COALESCE(checkoutNodeId, '') = COALESCE(?, '')
|
|
AND COALESCE(checkoutLeaseEpoch, 0) = COALESCE(?, 0)
|
|
`).run(
|
|
claim.agentId,
|
|
new Date().toISOString(),
|
|
claim.nodeId,
|
|
claim.runId,
|
|
claim.renewedAt,
|
|
claim.leaseEpoch,
|
|
taskId,
|
|
precondition.expectedCheckedOutBy ?? null,
|
|
precondition.expectedNodeId ?? null,
|
|
precondition.expectedLeaseEpoch ?? 0,
|
|
) as { changes: number };
|
|
|
|
const post = await this.getTask(taskId);
|
|
if (updateResult.changes === 0) {
|
|
return { ok: false, reason: "precondition_failed", current: post };
|
|
}
|
|
|
|
if (!post) {
|
|
return { ok: false, reason: "row_not_found", current: null };
|
|
}
|
|
|
|
return { ok: true, task: post };
|
|
}
|
|
|
|
async selectNextTaskForAgent(
|
|
agentId: string,
|
|
agent?: Pick<Agent, "id" | "role">,
|
|
): Promise<InboxTask | null> {
|
|
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<string, unknown>).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<string, Task>): 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>) => string | null;
|
|
/** Distinguishes user-initiated moves from engine-internal transitions. */
|
|
moveSource?: "user" | "engine";
|
|
/** Bypass in-review merge blocker checks for explicit engine-owned transitions. */
|
|
skipMergeBlocker?: boolean;
|
|
},
|
|
): Promise<Task> {
|
|
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 === toColumn) {
|
|
if (toColumn === "done" && 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 moveSource = options?.moveSource ?? "engine";
|
|
const fromColumn = task.column;
|
|
if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) {
|
|
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;
|
|
|
|
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
|
|
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt);
|
|
const segmentEndMs = Date.parse(task.columnMovedAt);
|
|
const segmentDeltaMs =
|
|
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
|
|
? Math.max(0, segmentEndMs - segmentStartMs)
|
|
: 0;
|
|
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
|
|
}
|
|
|
|
// 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.cumulativeActiveMs ??= 0;
|
|
if (!task.firstExecutionAt) {
|
|
task.firstExecutionAt = task.columnMovedAt;
|
|
}
|
|
if (!task.executionStartedAt) {
|
|
task.executionStartedAt = task.columnMovedAt;
|
|
}
|
|
task.userPaused = undefined;
|
|
}
|
|
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.overlapBlockedBy = undefined;
|
|
task.paused = undefined;
|
|
task.pausedByAgentId = undefined;
|
|
if (moveSource === "user" && toColumn === "todo") {
|
|
task.userPaused = true;
|
|
} else {
|
|
task.userPaused = 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 <path> <branch>`
|
|
// 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<string>();
|
|
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, source: moveSource });
|
|
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<void> {
|
|
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; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | 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<Task> {
|
|
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 = [];
|
|
}
|
|
|
|
let titleNormalized = false;
|
|
if (updates.title !== undefined) {
|
|
task.title = updates.title;
|
|
// FN-5077: load-time repair tolerates null normalized titles (title cleared instead of fragment persisted).
|
|
const normalizedTitle = normalizeTitleForTaskId(task.title, id);
|
|
if (normalizedTitle.changed) {
|
|
titleNormalized = true;
|
|
const removed = extractTaskIdTokens(task.title ?? "").filter((token) => token !== id.toUpperCase());
|
|
task.title = normalizedTitle.title ?? undefined;
|
|
task.log.push({
|
|
timestamp: new Date().toISOString(),
|
|
action: "Title normalized: stripped legacy task-id reference",
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
|
}
|
|
}
|
|
if (updates.description !== undefined) task.description = updates.description;
|
|
if (updates.sourceMetadataPatch === null) {
|
|
task.sourceMetadata = undefined;
|
|
} else if (updates.sourceMetadataPatch !== undefined) {
|
|
task.sourceMetadata = {
|
|
...(task.sourceMetadata ?? {}),
|
|
...updates.sourceMetadataPatch,
|
|
};
|
|
}
|
|
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;
|
|
}
|
|
if (updates.overlapBlockedBy === null) {
|
|
task.overlapBlockedBy = undefined;
|
|
} else if (updates.overlapBlockedBy !== undefined) {
|
|
task.overlapBlockedBy = updates.overlapBlockedBy;
|
|
}
|
|
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.worktrunkFallbackAlertedAt === null) {
|
|
task.worktrunkFallbackAlertedAt = undefined;
|
|
} else if (updates.worktrunkFallbackAlertedAt !== undefined) {
|
|
task.worktrunkFallbackAlertedAt = updates.worktrunkFallbackAlertedAt;
|
|
}
|
|
if (updates.worktrunkFailure === null) {
|
|
task.worktrunkFailure = undefined;
|
|
} else if (updates.worktrunkFailure !== undefined) {
|
|
task.worktrunkFailure = updates.worktrunkFailure;
|
|
}
|
|
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.worktreeSessionRetryCount === null) {
|
|
task.worktreeSessionRetryCount = undefined;
|
|
} else if (updates.worktreeSessionRetryCount !== undefined) {
|
|
task.worktreeSessionRetryCount = updates.worktreeSessionRetryCount;
|
|
}
|
|
if (updates.completionHandoffLimboRecoveryCount === null) {
|
|
task.completionHandoffLimboRecoveryCount = undefined;
|
|
} else if (updates.completionHandoffLimboRecoveryCount !== undefined) {
|
|
task.completionHandoffLimboRecoveryCount = updates.completionHandoffLimboRecoveryCount;
|
|
}
|
|
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.branchConflictRecoveryCount === null) {
|
|
task.branchConflictRecoveryCount = undefined;
|
|
} else if (updates.branchConflictRecoveryCount !== undefined) {
|
|
task.branchConflictRecoveryCount = updates.branchConflictRecoveryCount;
|
|
}
|
|
if (updates.reviewerContextRetryCount === null) {
|
|
task.reviewerContextRetryCount = undefined;
|
|
} else if (updates.reviewerContextRetryCount !== undefined) {
|
|
task.reviewerContextRetryCount = updates.reviewerContextRetryCount;
|
|
}
|
|
if (updates.reviewerFallbackRetryCount === null) {
|
|
task.reviewerFallbackRetryCount = undefined;
|
|
} else if (updates.reviewerFallbackRetryCount !== undefined) {
|
|
task.reviewerFallbackRetryCount = updates.reviewerFallbackRetryCount;
|
|
}
|
|
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.noCommitsExpected === null) {
|
|
task.noCommitsExpected = undefined;
|
|
} else if (updates.noCommitsExpected !== undefined) {
|
|
task.noCommitsExpected = updates.noCommitsExpected || undefined;
|
|
}
|
|
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.firstExecutionAt === null) {
|
|
task.firstExecutionAt = undefined;
|
|
} else if (updates.firstExecutionAt !== undefined) {
|
|
task.firstExecutionAt = updates.firstExecutionAt;
|
|
}
|
|
if (updates.cumulativeActiveMs === null) {
|
|
task.cumulativeActiveMs = undefined;
|
|
} else if (updates.cumulativeActiveMs !== undefined) {
|
|
task.cumulativeActiveMs = updates.cumulativeActiveMs;
|
|
}
|
|
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) {
|
|
const previousTracking = task.githubTracking;
|
|
const previousIssue = previousTracking?.issue;
|
|
const nextTracking: import("./types.js").TaskGithubTracking = {
|
|
...(previousTracking ?? {}),
|
|
...updates.githubTracking,
|
|
};
|
|
|
|
if (updates.githubTracking.repoOverride === null) {
|
|
nextTracking.repoOverride = undefined;
|
|
}
|
|
|
|
if (updates.githubTracking.enabled === false) {
|
|
nextTracking.enabled = false;
|
|
if (previousIssue) {
|
|
nextTracking.issue = undefined;
|
|
nextTracking.unlinkedAt = new Date().toISOString();
|
|
task.log.push({
|
|
timestamp: new Date().toISOString(),
|
|
action: "GitHub issue unlinked",
|
|
outcome: `${previousIssue.owner}/${previousIssue.repo}#${previousIssue.number}`,
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
}
|
|
task.log.push({
|
|
timestamp: new Date().toISOString(),
|
|
action: "GitHub tracking disabled",
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
}
|
|
|
|
if (updates.githubTracking.enabled === true) {
|
|
nextTracking.enabled = true;
|
|
task.log.push({
|
|
timestamp: new Date().toISOString(),
|
|
action: "GitHub tracking enabled",
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
}
|
|
|
|
if (updates.githubTracking.issue === null) {
|
|
if (previousIssue) {
|
|
task.log.push({
|
|
timestamp: new Date().toISOString(),
|
|
action: "GitHub issue unlinked",
|
|
outcome: `${previousIssue.owner}/${previousIssue.repo}#${previousIssue.number}`,
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
}
|
|
nextTracking.issue = undefined;
|
|
nextTracking.unlinkedAt = new Date().toISOString();
|
|
}
|
|
|
|
task.githubTracking = nextTracking;
|
|
}
|
|
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<string, unknown>)[k] !== undefined),
|
|
...(titleNormalized ? { titleNormalized: true } : {}),
|
|
},
|
|
});
|
|
} 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<desc>\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, source: "engine" });
|
|
}
|
|
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<Task> {
|
|
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;
|
|
task.userPaused = 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<Task> {
|
|
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;
|
|
}
|
|
|
|
if (status === "done") {
|
|
for (let i = 0; i < stepIndex; i++) {
|
|
const priorStatus = task.steps[i].status;
|
|
if (priorStatus === "pending" || priorStatus === "in-progress") {
|
|
const ts = new Date().toISOString();
|
|
task.updatedAt = ts;
|
|
task.log.push({
|
|
timestamp: ts,
|
|
action:
|
|
`Ignored out-of-order ${status} for step ${stepIndex} (${task.steps[stepIndex].name}) — ` +
|
|
`earlier step ${i} (${task.steps[i].name}) is still ${priorStatus}`,
|
|
});
|
|
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/non-skipped step
|
|
if (status === "done") {
|
|
while (
|
|
task.currentStep < task.steps.length &&
|
|
(task.steps[task.currentStep].status === "done" || task.steps[task.currentStep].status === "skipped")
|
|
) {
|
|
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<Task> {
|
|
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 > taskActivityLogEntryLimit) {
|
|
task.log.splice(0, task.log.length - taskActivityLogEntryLimit);
|
|
}
|
|
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 = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).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<TaskLogEntry[]>(row.log) || [];
|
|
log.push(entry);
|
|
if (log.length > taskActivityLogEntryLimit) {
|
|
log.splice(0, log.length - taskActivityLogEntryLimit);
|
|
}
|
|
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<TaskLogEntry[]> {
|
|
const rows = this.db.prepare(`SELECT log FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ log: string | null }>;
|
|
const mutations: TaskLogEntry[] = [];
|
|
for (const row of rows) {
|
|
const logEntries = fromJson<TaskLogEntry[]>(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<Record<string, unknown>>(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<import("./types.js").TaskStep[]> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Soft-delete a live task by setting tasks.deletedAt/updatedAt while leaving
|
|
* the row and on-disk task artifacts in place for potential recovery.
|
|
*
|
|
* Idempotent (FN-5127): calling deleteTask on an already-soft-deleted task is
|
|
* a no-op and does not re-emit task:deleted.
|
|
*/
|
|
async deleteTask(
|
|
id: string,
|
|
options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction },
|
|
): Promise<Task> {
|
|
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 soft-delete mutation.
|
|
this.flushAgentLogBuffer();
|
|
const task = this.readTaskFromDb(id, { includeDeleted: true });
|
|
if (!task) {
|
|
throw new Error(`Task ${id} not found`);
|
|
}
|
|
|
|
if (task.deletedAt) {
|
|
return task;
|
|
}
|
|
|
|
// 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(", ")}`,
|
|
});
|
|
}
|
|
|
|
let rewrittenDependents: Task[] = [];
|
|
this.db.transaction(() => {
|
|
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
|
const deletedAt = new Date().toISOString();
|
|
this.db.prepare("UPDATE tasks SET deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
|
|
this.clearLinkedAgentTaskIds(id, deletedAt);
|
|
this.db.bumpLastModified();
|
|
});
|
|
|
|
// Remove from cache if watcher is active
|
|
if (this.isWatching) this.taskCache.delete(id);
|
|
|
|
for (const dependentTask of rewrittenDependents) {
|
|
this.emit("task:updated", dependentTask);
|
|
}
|
|
|
|
const linkedFeature = this.missionStore?.getFeatureByTaskId(id);
|
|
if (linkedFeature) {
|
|
this.missionStore?.unlinkFeatureFromTask(linkedFeature.id);
|
|
}
|
|
|
|
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 rewriteDependentsForRemoval(taskId: string, dependentIds: string[]): Task[] {
|
|
const rewrittenDependents: Task[] = [];
|
|
|
|
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);
|
|
}
|
|
|
|
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<string[]> {
|
|
const branches = new Set<string>();
|
|
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 ${TaskStore.ACTIVE_TASKS_WHERE} AND ${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<import("./types.js").MergeDetails> {
|
|
const mergedAt = new Date().toISOString();
|
|
let commitSha: string | undefined;
|
|
let filesChanged: number | undefined;
|
|
let insertions: number | undefined;
|
|
let deletions: number | undefined;
|
|
let landedFiles: string[] | 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;
|
|
}
|
|
|
|
if (commitSha) {
|
|
const landedFilesResult = await this.runGitCommand(`git show --name-only --format= "${commitSha}"`);
|
|
if (landedFilesResult.exitCode === 0) {
|
|
const parsedLandedFiles = landedFilesResult.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
if (parsedLandedFiles.length > 0) {
|
|
landedFiles = Array.from(new Set(parsedLandedFiles));
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
commitSha,
|
|
landedFiles,
|
|
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<MergeResult> {
|
|
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;
|
|
if (mergeDetails.landedFiles && mergeDetails.landedFiles.length > 0) {
|
|
task.modifiedFiles = mergeDetails.landedFiles;
|
|
}
|
|
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<Task[]> {
|
|
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<Task> {
|
|
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, source: "engine" });
|
|
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, source: "engine" });
|
|
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<Task> {
|
|
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<Task> {
|
|
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.overlapBlockedBy = 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, source: "engine" });
|
|
return task;
|
|
});
|
|
}
|
|
|
|
private async moveToDone(task: Task, dir: string): Promise<void> {
|
|
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, source: "engine" });
|
|
}
|
|
|
|
private clearDoneTransientFields(task: Task): boolean {
|
|
const changed = task.status !== undefined
|
|
|| task.error !== undefined
|
|
|| task.worktree !== undefined
|
|
|| task.blockedBy !== undefined
|
|
|| task.overlapBlockedBy !== undefined
|
|
|| task.recoveryRetryCount !== undefined
|
|
|| task.nextRecoveryAt !== undefined;
|
|
|
|
task.status = undefined;
|
|
task.error = undefined;
|
|
task.worktree = undefined;
|
|
task.blockedBy = undefined;
|
|
task.overlapBlockedBy = 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<void> {
|
|
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<void> {
|
|
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.
|
|
// FN-5105: intentionally include soft-deleted rows here so a deletedAt
|
|
// transition can be observed and emit task:deleted exactly once.
|
|
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, source: "engine" });
|
|
} else {
|
|
this.emit("task:deleted", cached);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Yield to event loop before the expensive SELECT query
|
|
await new Promise<void>((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 (task.deletedAt) {
|
|
if (cached) {
|
|
this.taskCache.delete(task.id);
|
|
this.emit("task:deleted", cached);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
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, source: "engine" });
|
|
} 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<void>((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<TaskAttachment> {
|
|
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<Task> {
|
|
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<void> {
|
|
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 WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).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<void> {
|
|
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<string, unknown>): 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<Task> {
|
|
// 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<Task> {
|
|
// 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<Task> {
|
|
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<Task> {
|
|
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;
|
|
source?: "user" | "agent" | "github-review" | "github-review-comment";
|
|
externalId?: string;
|
|
reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED";
|
|
},
|
|
runContext?: RunMutationContext,
|
|
): Promise<Task> {
|
|
// 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 = [];
|
|
}
|
|
|
|
if (!task.comments) {
|
|
task.comments = [];
|
|
}
|
|
|
|
const externalSource = options?.source;
|
|
const externalId = options?.externalId;
|
|
if (externalSource && externalId) {
|
|
const existing = task.comments.find((entry) => entry.source === externalSource && entry.externalId === externalId);
|
|
if (existing) {
|
|
return task;
|
|
}
|
|
}
|
|
|
|
// Generate unique ID: timestamp + random suffix for collision resistance
|
|
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const now = new Date().toISOString();
|
|
|
|
const comment: import("./types.js").TaskComment = {
|
|
id: commentId,
|
|
text,
|
|
author,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
source: options?.source,
|
|
externalId: options?.externalId,
|
|
reviewState: options?.reviewState,
|
|
};
|
|
|
|
task.comments.push(comment);
|
|
task.updatedAt = now;
|
|
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, source: options?.source ?? null, externalId: options?.externalId ?? null },
|
|
});
|
|
} else {
|
|
await this.atomicWriteTaskJson(dir, task);
|
|
}
|
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
|
|
|
this.emit("task:updated", task);
|
|
return task;
|
|
});
|
|
|
|
const commentContextBase: Record<string, unknown> = {
|
|
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<TaskDocument[]> {
|
|
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<TaskDocumentWithTask[]> {
|
|
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<TaskDocument | null> {
|
|
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<TaskDocument> {
|
|
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 = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).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<TaskDocumentRevision[]> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
|
|
private getTaskPrInfos(task: Task): import("./types.js").PrInfo[] {
|
|
return [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : []))];
|
|
}
|
|
|
|
private resolvePrimaryPrInfo(prInfos: import("./types.js").PrInfo[]): import("./types.js").PrInfo | undefined {
|
|
// Primary selection rule: prefer the most-recently-updated open PR; if none are open,
|
|
// fall back to the first linked PR for stable back-compat rendering.
|
|
const openPrs = prInfos.filter((entry) => entry.status === "open");
|
|
if (openPrs.length === 0) return prInfos[0];
|
|
const sorted = [...openPrs].sort((a, b) => {
|
|
const aTs = Date.parse(a.lastCheckedAt ?? a.lastCommentAt ?? "");
|
|
const bTs = Date.parse(b.lastCheckedAt ?? b.lastCommentAt ?? "");
|
|
if (Number.isFinite(aTs) && Number.isFinite(bTs)) return bTs - aTs;
|
|
if (Number.isFinite(aTs)) return -1;
|
|
if (Number.isFinite(bTs)) return 1;
|
|
return 0;
|
|
});
|
|
return sorted[0] ?? prInfos[0];
|
|
}
|
|
|
|
private upsertPrInfoByNumber(prInfos: import("./types.js").PrInfo[], prInfo: import("./types.js").PrInfo): import("./types.js").PrInfo[] {
|
|
const idx = prInfos.findIndex((entry) => entry.number === prInfo.number);
|
|
if (idx >= 0) {
|
|
const next = [...prInfos];
|
|
next[idx] = { ...next[idx], ...prInfo };
|
|
return next;
|
|
}
|
|
return [prInfo, ...prInfos];
|
|
}
|
|
|
|
/**
|
|
* Update or clear PR information for a task.
|
|
* Updates task.json atomically and emits `task:updated` event.
|
|
*/
|
|
async updatePrInfo(
|
|
id: string,
|
|
prInfo: import("./types.js").PrInfo | null,
|
|
): Promise<Task> {
|
|
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;
|
|
|
|
let prInfos = this.getTaskPrInfos(task);
|
|
if (prInfo) {
|
|
prInfos = this.upsertPrInfoByNumber(prInfos, 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 {
|
|
if (previous?.number !== undefined) {
|
|
task.log.push({ timestamp: new Date().toISOString(), action: "PR unlinked", outcome: `PR #${previous.number} removed` });
|
|
}
|
|
prInfos = [];
|
|
}
|
|
|
|
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
|
|
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
|
task.updatedAt = new Date().toISOString();
|
|
|
|
await this.atomicWriteTaskJson(dir, task);
|
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
|
if (badgeChanged || linkChanged || !prInfo) this.emit("task:updated", task);
|
|
return task;
|
|
});
|
|
}
|
|
|
|
async addPrInfo(id: string, prInfo: import("./types.js").PrInfo): Promise<Task | undefined> {
|
|
return this.withTaskLock(id, async () => {
|
|
const dir = this.taskDir(id);
|
|
const task = await this.readTaskJson(dir);
|
|
let prInfos = this.getTaskPrInfos(task);
|
|
const existingIndex = prInfos.findIndex((entry) => entry.number === prInfo.number);
|
|
if (existingIndex >= 0) {
|
|
prInfos[existingIndex] = { ...prInfos[existingIndex], ...prInfo };
|
|
} else {
|
|
prInfos = [prInfo, ...prInfos];
|
|
}
|
|
task.prInfos = prInfos;
|
|
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
|
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 updatePrInfoByNumber(id: string, number: number, patch: Partial<import("./types.js").PrInfo>): Promise<Task | undefined> {
|
|
return this.withTaskLock(id, async () => {
|
|
const dir = this.taskDir(id);
|
|
const task = await this.readTaskJson(dir);
|
|
const prInfos = this.getTaskPrInfos(task);
|
|
const index = prInfos.findIndex((entry) => entry.number === number);
|
|
if (index < 0) {
|
|
storeLog.warn(`[store] updatePrInfoByNumber: PR #${number} not found for ${id}`);
|
|
return task;
|
|
}
|
|
prInfos[index] = { ...prInfos[index], ...patch };
|
|
task.prInfos = prInfos;
|
|
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
|
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 removePrInfoByNumber(id: string, number: number): Promise<Task | undefined> {
|
|
return this.withTaskLock(id, async () => {
|
|
const dir = this.taskDir(id);
|
|
const task = await this.readTaskJson(dir);
|
|
const prInfos = this.getTaskPrInfos(task).filter((entry) => entry.number !== number);
|
|
if ((task.prInfos ?? []).length === prInfos.length && task.prInfo?.number !== number) {
|
|
storeLog.warn(`[store] removePrInfoByNumber: PR #${number} not found for ${id}`);
|
|
return task;
|
|
}
|
|
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
|
|
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
/**
|
|
* Move a PR-linked task to done when the external PR is observed as merged.
|
|
*
|
|
* Column policy: this auto-transition only applies to tasks currently in
|
|
* `in-review`. Other columns remain owned by executor/scheduler flows.
|
|
*/
|
|
async applyPrMergedTransition(
|
|
taskId: string,
|
|
ctx?: { agentId?: string; runId?: string },
|
|
): Promise<{ moved: boolean; skipped?: "already-done" | "not-merged" | "wrong-column" | "paused" }> {
|
|
const task = await this.getTask(taskId);
|
|
if (task.column === "done") {
|
|
return { moved: false, skipped: "already-done" };
|
|
}
|
|
if (task.paused) {
|
|
return { moved: false, skipped: "paused" };
|
|
}
|
|
if (task.prInfo?.status !== "merged") {
|
|
return { moved: false, skipped: "not-merged" };
|
|
}
|
|
if (task.column !== "in-review") {
|
|
storeLog.warn(`[store] applyPrMergedTransition skipped for ${taskId}: column=${task.column}`);
|
|
return { moved: false, skipped: "wrong-column" };
|
|
}
|
|
|
|
const freshTask = await this.getTask(taskId);
|
|
if (freshTask.column === "done") {
|
|
return { moved: false, skipped: "already-done" };
|
|
}
|
|
if (freshTask.paused) {
|
|
return { moved: false, skipped: "paused" };
|
|
}
|
|
if (freshTask.prInfo?.status !== "merged") {
|
|
return { moved: false, skipped: "not-merged" };
|
|
}
|
|
if (freshTask.column !== "in-review") {
|
|
storeLog.warn(`[store] applyPrMergedTransition skipped for ${taskId}: column=${freshTask.column}`);
|
|
return { moved: false, skipped: "wrong-column" };
|
|
}
|
|
|
|
await this.moveTask(taskId, "done", {
|
|
moveSource: "engine",
|
|
preserveProgress: true,
|
|
preserveWorktree: true,
|
|
skipMergeBlocker: true,
|
|
});
|
|
|
|
if (ctx?.agentId && ctx?.runId) {
|
|
this.recordRunAuditEvent({
|
|
taskId,
|
|
agentId: ctx.agentId,
|
|
runId: ctx.runId,
|
|
domain: "database",
|
|
mutationType: "pr:merged-auto-done",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
prNumber: freshTask.prInfo?.number,
|
|
mergeMethod: freshTask.prInfo?.autoMergeStrategy,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { moved: true };
|
|
}
|
|
|
|
async updateIssueInfo(
|
|
id: string,
|
|
issueInfo: import("./types.js").IssueInfo | null,
|
|
): Promise<Task> {
|
|
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<Task> {
|
|
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<Task> {
|
|
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<Task> {
|
|
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<AgentLogEntry[]> {
|
|
// 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<Record<string, unknown>>;
|
|
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<Record<string, unknown>>;
|
|
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<number> {
|
|
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<AgentLogEntry[]> {
|
|
// 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<Record<string, unknown>>;
|
|
return rows.map((row) => this.mapAgentLogRow(row));
|
|
}
|
|
|
|
async importLegacyAgentLogs(): Promise<number> {
|
|
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<string, unknown>;
|
|
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<void> {
|
|
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<import("./types.js").ArchivedTaskEntry[]> {
|
|
return this.archiveDb.list();
|
|
}
|
|
|
|
/**
|
|
* Find a specific task in the archive by ID.
|
|
*/
|
|
async findInArchive(id: string): Promise<import("./types.js").ArchivedTaskEntry | undefined> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<Task> {
|
|
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,
|
|
noCommitsExpected: entry.noCommitsExpected,
|
|
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<import("./types.js").WorkflowStep> {
|
|
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 || "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 ?? "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<import("./types.js").WorkflowStep[]> {
|
|
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<import("./types.js").WorkflowStep | undefined> {
|
|
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<import("./types.js").WorkflowStepInput>): Promise<import("./types.js").WorkflowStep> {
|
|
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.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 = ?,
|
|
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.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<void> {
|
|
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;
|
|
}
|
|
if (this.secretsCentralCore) {
|
|
void this.secretsCentralCore.close();
|
|
this.secretsCentralCore = null;
|
|
}
|
|
this.secretsStore = 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;
|
|
}
|
|
|
|
async getSecretsStore(): Promise<SecretsStore> {
|
|
if (this.secretsStore) {
|
|
return this.secretsStore;
|
|
}
|
|
|
|
const central = new CentralCore(this.getFusionDir());
|
|
await central.init();
|
|
this.secretsCentralCore = central;
|
|
const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db;
|
|
if (!centralDb) {
|
|
throw new Error("Central database unavailable for secrets store");
|
|
}
|
|
const masterKeyManager = new MasterKeyManager();
|
|
const masterKeyProvider = () => masterKeyManager.getOrCreateKey();
|
|
this.secretsStore = new SecretsStore(this.db, centralDb, masterKeyProvider);
|
|
return this.secretsStore;
|
|
}
|
|
|
|
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<Settings>(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<ActivityLogEntry, "id" | "timestamp">): Promise<ActivityLogEntry> {
|
|
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<ActivityLogEntry[]> {
|
|
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,
|
|
}));
|
|
}
|
|
|
|
async getTaskMovedCountsByDay(options: {
|
|
since: string;
|
|
until: string;
|
|
fromColumn?: string;
|
|
toColumn?: string;
|
|
}): Promise<Record<string, number>> {
|
|
let sql =
|
|
"SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS count FROM activityLog WHERE type = 'task:moved' AND timestamp > ? AND timestamp <= ?";
|
|
const params: (string | number)[] = [options.since, options.until];
|
|
|
|
if (options.fromColumn) {
|
|
sql += " AND json_extract(metadata, '$.from') = ?";
|
|
params.push(options.fromColumn);
|
|
}
|
|
|
|
if (options.toColumn) {
|
|
sql += " AND json_extract(metadata, '$.to') = ?";
|
|
params.push(options.toColumn);
|
|
}
|
|
|
|
sql += " GROUP BY substr(timestamp, 1, 10)";
|
|
|
|
const rows = this.db.prepare(sql).all(...params) as Array<{ day: string; count: number }>;
|
|
const countsByDay: Record<string, number> = {};
|
|
for (const row of rows) {
|
|
countsByDay[row.day] = row.count;
|
|
}
|
|
return countsByDay;
|
|
}
|
|
|
|
async getInReviewDurationEvents(options: { since: string; until: string }): Promise<ActivityLogEntry[]> {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT * FROM activityLog
|
|
WHERE type = 'task:moved'
|
|
AND timestamp > ?
|
|
AND timestamp <= ?
|
|
AND (
|
|
json_extract(metadata, '$.to') = 'in-review'
|
|
OR (
|
|
json_extract(metadata, '$.from') = 'in-review'
|
|
AND json_extract(metadata, '$.to') = 'done'
|
|
)
|
|
)
|
|
ORDER BY timestamp ASC
|
|
LIMIT ?`,
|
|
)
|
|
.all(options.since, options.until, 200_000) 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,
|
|
}));
|
|
}
|
|
|
|
async getTaskMergedTaskIds(options: { since: string; until: string }): Promise<Set<string>> {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT DISTINCT taskId FROM activityLog
|
|
WHERE type = 'task:merged'
|
|
AND timestamp > ?
|
|
AND timestamp <= ?
|
|
AND taskId IS NOT NULL`,
|
|
)
|
|
.all(options.since, options.until) as Array<{ taskId: string }>;
|
|
|
|
return new Set(rows.map((row) => row.taskId));
|
|
}
|
|
|
|
/**
|
|
* Clear all activity log entries.
|
|
* Use with caution - this permanently deletes activity history.
|
|
*/
|
|
async clearActivityLog(): Promise<void> {
|
|
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<TaskMetadataSnapshot> {
|
|
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<ActivityLogSnapshot> {
|
|
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<TaskCommitAssociation, "id" | "createdAt" | "updatedAt"> & { id?: string },
|
|
): Promise<TaskCommitAssociation> {
|
|
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<TaskCommitAssociation[]> {
|
|
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<Omit<TaskCommitAssociation, "id" | "createdAt" | "updatedAt" | "taskLineageId">>,
|
|
): Promise<void> {
|
|
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) ────────────────────────
|
|
|
|
}
|