13374 lines
507 KiB
TypeScript
13374 lines
507 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, ColumnId, 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, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } 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 { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js";
|
|
import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js";
|
|
import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js";
|
|
import {
|
|
type PluginGateVerdict,
|
|
findWorkflowColumn,
|
|
resolveColumnPluginGates,
|
|
} from "./plugin-gate-verdict.js";
|
|
import { getTraitRegistry, assertColumnTraitsValid } from "./trait-registry.js";
|
|
import { resolveColumnCapacity } from "./workflow-capacity.js";
|
|
import {
|
|
OccupiedColumnsError,
|
|
assertRehomeTargetValid,
|
|
computeRemovedOccupiedColumns,
|
|
resolveEntryColumnId,
|
|
resolveSwitchReconciliation,
|
|
runReconciliationAbort,
|
|
} from "./workflow-reconciliation.js";
|
|
import {
|
|
type DefaultWorkflowMoveContext,
|
|
applyDefaultWorkflowMoveEffects,
|
|
evaluateMergeBlockerGuard,
|
|
registerDefaultWorkflowHooks,
|
|
} from "./default-workflow-hooks.js";
|
|
import {
|
|
type TransitionRejection,
|
|
makeTransitionRejection,
|
|
makeTransitionPending,
|
|
} from "./transition-types.js";
|
|
import { writeTransitionPending, clearTransitionPending } from "./transition-pending.js";
|
|
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
|
|
import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js";
|
|
// Side-effect import: registers the 14 built-in trait DEFINITIONS into the
|
|
// shared trait registry on load (the flag-ON path resolves traits by id).
|
|
import "./builtin-traits.js";
|
|
import type {
|
|
WorkflowDefinition,
|
|
WorkflowDefinitionInput,
|
|
WorkflowDefinitionUpdate,
|
|
WorkflowNodeLayout,
|
|
} from "./workflow-definition-types.js";
|
|
import { compileWorkflowToSteps } from "./workflow-compiler.js";
|
|
import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js";
|
|
import {
|
|
WORKFLOW_PARITY_OBSERVED_MUTATION,
|
|
WORKFLOW_PARITY_DRIFT_MUTATION,
|
|
DUAL_ACCEPT_PARITY_MUTATIONS,
|
|
computeWorkflowColumnsGraduationReport,
|
|
type WorkflowParityDiff,
|
|
type WorkflowParitySummary,
|
|
type WorkflowColumnsGraduationReport,
|
|
} from "./workflow-parity.js";
|
|
|
|
/** Tags WorkflowStep rows materialized by compiling a workflow so they can be
|
|
* filtered out of the user-facing step manager and cleaned up on re-selection. */
|
|
const WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX = "workflow:";
|
|
import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js";
|
|
import { validateLocale } from "./settings-validation.js";
|
|
import { normalizeTaskPriority } from "./task-priority.js";
|
|
import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js";
|
|
import { allowsAutoMergeProcessing } from "./task-merge.js";
|
|
import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js";
|
|
import { GlobalSettingsStore } from "./global-settings.js";
|
|
import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js";
|
|
import { ArchiveDatabase } from "./archive-db.js";
|
|
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
|
|
import { buildSnippet, extractGoalCitations } from "./goal-citation-extractor.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 { GoalStore } from "./goal-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 {
|
|
appendAgentLogEntriesSync,
|
|
countAgentLogEntries,
|
|
pruneAgentLogFiles as pruneAgentLogFileEntries,
|
|
readAgentLogEntries,
|
|
readAgentLogEntriesByTimeRange,
|
|
} from "./agent-log-file-store.js";
|
|
import { truncateAgentLogDetail } from "./agent-log-constants.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 {
|
|
assertNotLinkedWorktreeOfExistingProject,
|
|
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;
|
|
pausedReason: string | null;
|
|
userPaused: number | null;
|
|
baseBranch: string | null;
|
|
executionStartBranch: string | null;
|
|
branch: string | null;
|
|
autoMerge: number | 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;
|
|
resumeLimboCount: number | null;
|
|
resumeLimboTipSha: string | null;
|
|
resumeLimboStepSignature: string | 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;
|
|
mergeTransientRetryCount: 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;
|
|
scopeAutoWiden: 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;
|
|
allowResurrection: number | 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>;
|
|
// groupId is optional: only shared-mode members carry one. A non-shared
|
|
// member persists source/assignmentMode without a groupId, so a missing or
|
|
// empty groupId must NOT discard the whole context.
|
|
const groupId = typeof candidate.groupId === "string"
|
|
? candidate.groupId.trim() || undefined
|
|
: undefined;
|
|
if (candidate.source !== "planning" && candidate.source !== "mission" && candidate.source !== "new-task") 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 ? { 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]: {
|
|
...(branchContext.groupId?.trim()
|
|
? { groupId: branchContext.groupId.trim() }
|
|
: {}),
|
|
source: branchContext.source,
|
|
assignmentMode: branchContext.assignmentMode,
|
|
...(branchContext.inheritedBaseBranch ? { inheritedBaseBranch: branchContext.inheritedBaseBranch } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
interface BranchGroupRow {
|
|
id: string;
|
|
sourceType: "mission" | "planning" | "new-task";
|
|
sourceId: string;
|
|
branchName: string;
|
|
worktreePath: string | null;
|
|
autoMerge: number;
|
|
prState: "none" | "open" | "merged" | "closed";
|
|
prUrl: string | null;
|
|
prNumber: number | null;
|
|
status: "open" | "finalized" | "abandoned";
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
closedAt: number | null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface GoalCitationRow {
|
|
id: number;
|
|
goalId: string;
|
|
agentId: string;
|
|
taskId: string | null;
|
|
surface: GoalCitationSurface;
|
|
sourceRef: string;
|
|
snippet: string;
|
|
timestamp: 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;
|
|
}
|
|
|
|
interface MergeQueueRow {
|
|
taskId: string;
|
|
enqueuedAt: string;
|
|
priority: string;
|
|
leasedBy: string | null;
|
|
leasedAt: string | null;
|
|
leaseExpiresAt: string | null;
|
|
attemptCount: number;
|
|
lastError: string | null;
|
|
}
|
|
|
|
interface MergeRequestRow {
|
|
taskId: string;
|
|
state: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
attemptCount: number;
|
|
lastError: string | null;
|
|
}
|
|
|
|
interface CompletionHandoffMarkerRow {
|
|
taskId: string;
|
|
acceptedAt: string;
|
|
source: string;
|
|
}
|
|
|
|
/** 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 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 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" | "scheduler" }];
|
|
"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 type TaskDependencyMutation =
|
|
| { operation: "add"; dependency: string }
|
|
| { operation: "remove"; dependency: string }
|
|
| { operation: "replace"; from: string; to: string }
|
|
| { operation: "set"; dependencies: string[] };
|
|
|
|
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 TaskDeletedError extends Error {
|
|
constructor(
|
|
public readonly taskId: string,
|
|
public readonly deletedAt: string,
|
|
) {
|
|
super(`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be read or mutated`);
|
|
this.name = "TaskDeletedError";
|
|
}
|
|
}
|
|
|
|
export class TombstonedTaskResurrectionError extends Error {
|
|
constructor(
|
|
public readonly taskId: string,
|
|
public readonly deletedAt: string,
|
|
public readonly allowResurrection: boolean,
|
|
) {
|
|
super(
|
|
`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be recreated without forceResurrect: true. `
|
|
+ `Operator unlock: allowResurrection=${allowResurrection}`,
|
|
);
|
|
this.name = "TombstonedTaskResurrectionError";
|
|
}
|
|
}
|
|
|
|
export class TaskHasLineageChildrenError extends Error {
|
|
readonly taskId: string;
|
|
readonly childIds: string[];
|
|
|
|
constructor(taskId: string, childIds: string[]) {
|
|
super(
|
|
`Cannot delete task ${taskId}: still referenced as a lineage parent by ${childIds.join(", ")}. ` +
|
|
`Pass { removeLineageReferences: true } to clear these references before deleting.`,
|
|
);
|
|
this.name = "TaskHasLineageChildrenError";
|
|
this.taskId = taskId;
|
|
this.childIds = childIds;
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
function sanitizeFileScopeInPromptContent(prompt: string): { sanitized: string; dropped: string[]; kept: string[] } {
|
|
const headingMatch = prompt.match(/^##\s+File\s+Scope\s*$/m);
|
|
if (!headingMatch) {
|
|
return { sanitized: prompt, dropped: [], kept: [] };
|
|
}
|
|
|
|
const startIdx = headingMatch.index! + headingMatch[0].length;
|
|
const rest = prompt.slice(startIdx);
|
|
const nextHeading = rest.search(/\n##?\s/);
|
|
const endIdx = nextHeading === -1 ? prompt.length : startIdx + nextHeading;
|
|
const section = prompt.slice(startIdx, endIdx);
|
|
const { valid: kept, invalid: dropped } = validateFileScopeInPromptContent(prompt);
|
|
if (dropped.length === 0) {
|
|
return { sanitized: prompt, dropped, kept };
|
|
}
|
|
|
|
const sanitizedSection = section
|
|
.split("\n")
|
|
.filter((line) => {
|
|
const tokens = Array.from(line.matchAll(/`([^`]+)`/g), (match) => match[1]);
|
|
if (tokens.length === 0) return true;
|
|
return tokens.every((token) => isValidFileScopeEntry(token));
|
|
})
|
|
.join("\n");
|
|
|
|
return {
|
|
sanitized: `${prompt.slice(0, startIdx)}${sanitizedSection}${prompt.slice(endIdx)}`,
|
|
dropped,
|
|
kept,
|
|
};
|
|
}
|
|
|
|
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 DependencyCycleError extends Error {
|
|
readonly code = "DEPENDENCY_CYCLE" as const;
|
|
|
|
constructor(
|
|
readonly taskId: string,
|
|
readonly cyclePath: readonly string[],
|
|
) {
|
|
super(`Dependency cycle detected for ${taskId}: ${cyclePath.join(" → ")}`);
|
|
this.name = "DependencyCycleError";
|
|
}
|
|
}
|
|
|
|
export function detectDependencyCycle(
|
|
candidateTaskId: string,
|
|
candidateDependencies: readonly string[],
|
|
lookupDependencies: (taskId: string) => readonly string[] | undefined,
|
|
): string[] | null {
|
|
const visited = new Set<string>();
|
|
|
|
for (const dep of candidateDependencies) {
|
|
if (dep === candidateTaskId) {
|
|
return [candidateTaskId, candidateTaskId];
|
|
}
|
|
|
|
const initialDeps = lookupDependencies(dep);
|
|
if (!initialDeps) continue;
|
|
|
|
const stack: Array<{ taskId: string; deps: readonly string[]; index: number }> = [
|
|
{ taskId: dep, deps: initialDeps, index: 0 },
|
|
];
|
|
const path = [candidateTaskId, dep];
|
|
|
|
while (stack.length > 0) {
|
|
const top = stack[stack.length - 1]!;
|
|
if (top.index >= top.deps.length) {
|
|
stack.pop();
|
|
path.pop();
|
|
continue;
|
|
}
|
|
|
|
const next = top.deps[top.index++]!;
|
|
if (next === candidateTaskId) {
|
|
return [...path, candidateTaskId];
|
|
}
|
|
|
|
if (visited.has(next)) {
|
|
continue;
|
|
}
|
|
|
|
const nextDeps = lookupDependencies(next);
|
|
if (!nextDeps) {
|
|
visited.add(next);
|
|
continue;
|
|
}
|
|
|
|
visited.add(next);
|
|
stack.push({ taskId: next, deps: nextDeps, index: 0 });
|
|
path.push(next);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export class MergeQueueTaskNotFoundError extends Error {
|
|
constructor(public readonly taskId: string) {
|
|
super(`Cannot enqueue merge queue entry for missing task ${taskId}`);
|
|
this.name = "MergeQueueTaskNotFoundError";
|
|
}
|
|
}
|
|
|
|
export class MergeQueueInvalidColumnError extends Error {
|
|
constructor(
|
|
public readonly taskId: string,
|
|
public readonly column: Column,
|
|
) {
|
|
super(`Cannot enqueue merge queue entry for task ${taskId} in column ${column}; only in-review is allowed`);
|
|
this.name = "MergeQueueInvalidColumnError";
|
|
}
|
|
}
|
|
|
|
export class MergeQueueLeaseOwnershipError extends Error {
|
|
constructor(
|
|
public readonly taskId: string,
|
|
public readonly workerId: string,
|
|
public readonly currentOwner: string | null,
|
|
) {
|
|
super(
|
|
currentOwner
|
|
? `Worker ${workerId} does not own merge queue lease for ${taskId}; current owner is ${currentOwner}`
|
|
: `Worker ${workerId} cannot release merge queue lease for ${taskId}; the entry is not currently leased`,
|
|
);
|
|
this.name = "MergeQueueLeaseOwnershipError";
|
|
}
|
|
}
|
|
|
|
export class InvalidMergeQueueLeaseDurationError extends Error {
|
|
constructor(public readonly leaseDurationMs: number) {
|
|
super(`merge queue leaseDurationMs must be > 0 (received ${leaseDurationMs})`);
|
|
this.name = "InvalidMergeQueueLeaseDurationError";
|
|
}
|
|
}
|
|
|
|
export class HandoffInvariantViolationError extends Error {
|
|
constructor(
|
|
public readonly taskId: string,
|
|
public readonly fromColumn: Column,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "HandoffInvariantViolationError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown by the flag-ON (`workflowColumns`) `moveTaskInternal` path when a move
|
|
* is rejected, carrying the typed {@link TransitionRejection} (KTD-3/R13). The
|
|
* existing callers of `moveTask` catch thrown `Error`s (e.g. the dashboard move
|
|
* route inspects `err.message`), so the rejection rides on an `Error` subclass
|
|
* — `.message` reproduces the legacy human-readable string so flag-ON callers
|
|
* that only read the message keep working, while `.rejection` exposes the
|
|
* machine-stable code/messageKey/retryable for surfaces that want it.
|
|
*
|
|
* The FLAG-OFF path still throws the bare legacy `Error` strings unchanged
|
|
* (zero behavior change while the flag is off — proven by the characterization
|
|
* suite).
|
|
*/
|
|
export class TransitionRejectionError extends Error {
|
|
readonly rejection: TransitionRejection;
|
|
constructor(rejection: TransitionRejection, message: string) {
|
|
super(message);
|
|
this.name = "TransitionRejectionError";
|
|
this.rejection = rejection;
|
|
}
|
|
}
|
|
|
|
interface MoveTaskOptions {
|
|
preserveResumeState?: boolean;
|
|
preserveProgress?: boolean;
|
|
preserveWorktree?: boolean;
|
|
preserveStatus?: boolean;
|
|
allocateWorktree?: (reservedNames: Set<string>) => string | null;
|
|
moveSource?: "user" | "engine" | "scheduler";
|
|
skipMergeBlocker?: boolean;
|
|
allowDirectInReviewMove?: boolean;
|
|
/**
|
|
* KTD-9: engine/recovery moves bypass trait guards and abort-on-exit effects
|
|
* (the generalization of `skipMergeBlocker`). It NEVER bypasses capacity
|
|
* (KTD-10). Engine-internal only: HTTP move endpoints hardcode it off and must
|
|
* never forward a caller-supplied value (mirrors the hardcoded
|
|
* `moveSource: "user"` posture). When unset, the flag-ON path derives it from
|
|
* `moveSource === "engine"` plus `skipMergeBlocker`.
|
|
*/
|
|
bypassGuards?: boolean;
|
|
/**
|
|
* U5 (R15/R20): a workflow-reconciliation re-home move (switch/edit/delete).
|
|
* Unlike `bypassGuards` (which skips trait guards but still enforces the
|
|
* column-graph adjacency, so the U4 parity matrix is unaffected), a recovery
|
|
* re-home must reach the new workflow's entry column from ANY current column —
|
|
* a card that would otherwise be stranded in a column its (new) workflow does
|
|
* not define. So this additionally skips the adjacency check (step 2). The
|
|
* structural unknown-column check (step 1) and the in-txn capacity check
|
|
* (KTD-10) still apply. Engine-internal only: never forwarded from an HTTP
|
|
* endpoint. When set, implies `bypassGuards`.
|
|
*/
|
|
recoveryRehome?: boolean;
|
|
}
|
|
|
|
interface MoveTaskInternalOptions {
|
|
fromHandoff: boolean;
|
|
runContext?: Pick<RunMutationContext, "runId" | "agentId"> | { runId?: string; agentId?: string };
|
|
ownerAgentId?: string | null;
|
|
evidence?: HandoffToReviewOptions["evidence"];
|
|
now?: string;
|
|
}
|
|
|
|
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
|
|
/** U6: sentinel effective-workflow id for default-workflow (null-selection)
|
|
* tasks, so they all share one per-column capacity pool (KTD-10). It is not a
|
|
* real workflow row id (no `builtin:`/custom collision possible). */
|
|
private static readonly DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__";
|
|
|
|
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;
|
|
/**
|
|
* When true, the activity-log listeners skip recording. Set by the polling
|
|
* loop (`checkForChanges`) so that events re-emitted after observing another
|
|
* TaskStore instance's DB write don't double- or triple-log to activityLog.
|
|
* The in-process emit path (moveTask, updateTask, etc.) leaves this false
|
|
* and remains the sole source of truth for activity rows.
|
|
*/
|
|
private suppressActivityLogForPollingEmit = 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();
|
|
/**
|
|
* U8 (KTD-2): pre-evaluated plugin gate verdicts, keyed `taskId` → `toColumn`
|
|
* → recorded verdicts (one per plugin gate trait). A plugin gate is evaluated
|
|
* OUTSIDE the lock by the engine's trait adapter; the verdict is recorded here
|
|
* and re-checked cheaply in-lock at move time so plugin code never blocks or
|
|
* wedges the task lock. Kept in-memory (minimal/surgical per U8); the
|
|
* `plugin-gate-verdict.ts` seam can later back this with SQLite.
|
|
*/
|
|
private pluginGateVerdicts: Map<string, Map<string, PluginGateVerdict[]>> = 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;
|
|
private workflowDefinitionsCache: WorkflowDefinition[] | 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;
|
|
/** One-shot startup sweep flag for clearing stale pause fields on done tasks. */
|
|
private donePauseBackfillDone = false;
|
|
/** 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 GoalStore instance */
|
|
private goalStore: GoalStore | 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: AgentLogEntry["type"];
|
|
detail: string | null;
|
|
agent: AgentLogEntry["agent"] | 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");
|
|
assertNotLinkedWorktreeOfExistingProject(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);
|
|
}
|
|
|
|
private emitTaskLifecycleEventSafely(
|
|
event: "task:created" | "task:updated",
|
|
args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"],
|
|
): boolean {
|
|
const listeners = super.listeners(event) as Array<(...listenerArgs: typeof args) => unknown>;
|
|
if (listeners.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const [task] = args;
|
|
const taskId = task && typeof task === "object" && "id" in task ? String(task.id) : "unknown";
|
|
|
|
for (const listener of listeners) {
|
|
try {
|
|
const result = listener(...args);
|
|
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
|
|
void Promise.resolve(result).catch((error) => {
|
|
storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
|
|
// U4: register the default-workflow trait hook implementations into the
|
|
// shared trait registry (the flag-ON moveTaskInternal path resolves the
|
|
// legacy per-column effects through these). Idempotent; built-in trait
|
|
// DEFINITIONS self-register on import of ./builtin-traits.js (pulled in
|
|
// transitively via default-workflow-hooks / trait-registry).
|
|
registerDefaultWorkflowHooks();
|
|
|
|
// Initialize SQLite database
|
|
if (!this._db) {
|
|
// Startup corruption guard: before opening, detect a malformed fusion.db
|
|
// (a node:sqlite SIGSEGV mid-write can leave the B-tree corrupt in a way
|
|
// that still opens) and rebuild it via sqlite3 .recover, preserving the
|
|
// corrupt original. Disk-backed only; opt out with FUSION_DISABLE_DB_AUTORECOVER.
|
|
if (!this.inMemoryDb && process.env.FUSION_DISABLE_DB_AUTORECOVER !== "1") {
|
|
try {
|
|
const recovery = Database.recoverIfCorrupt(this.fusionDir);
|
|
if (recovery.status === "recovered") {
|
|
storeLog.warn("Recovered corrupt fusion.db on startup", {
|
|
phase: "init:db-autorecover",
|
|
corruptBackupPath: recovery.corruptBackupPath,
|
|
errors: recovery.errors?.slice(0, 5),
|
|
});
|
|
} else if (recovery.status === "failed") {
|
|
storeLog.error("fusion.db is corrupt and automatic recovery failed", {
|
|
phase: "init:db-autorecover",
|
|
errors: recovery.errors?.slice(0, 5),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
storeLog.warn("Startup db corruption guard threw — continuing to open", {
|
|
phase: "init:db-autorecover",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
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.migrateAgentLogEntriesToFilesOnce();
|
|
await this.cleanupNoOpTaskMovedActivityRowsOnce();
|
|
// Re-run init when migrations are pending, or when the deferred
|
|
// agentLogEntries drop still needs to fire: migration 102 skips the
|
|
// destructive drop until migrateAgentLogEntriesToFilesOnce() above writes
|
|
// the __meta guard, but migrations 103+ bump the schema version past 102
|
|
// on the first pass, so the version check alone no longer triggers the
|
|
// second pass that performs the drop.
|
|
const legacyAgentLogTableRemains =
|
|
this.db
|
|
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1")
|
|
.get() !== undefined;
|
|
if (this.db.getSchemaVersion() < SCHEMA_VERSION || legacyAgentLogTableRemains) {
|
|
this.db.init();
|
|
}
|
|
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),
|
|
});
|
|
}
|
|
|
|
// U12: workflow-columns integrity pass. When the flag is ON, audit + re-home
|
|
// any task whose stored column is no longer valid in its resolved workflow
|
|
// (KTD-1 guarantees zero rewrites for healthy legacy rows, so this is a
|
|
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
|
|
try {
|
|
const settings = await this.getSettingsFast();
|
|
if (isWorkflowColumnsEnabled(settings)) {
|
|
await this.runWorkflowColumnsIntegrityPass();
|
|
}
|
|
} catch (err) {
|
|
storeLog.warn("workflowColumns integrity pass failed during init", {
|
|
phase: "init:workflow-columns-integrity",
|
|
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,
|
|
pausedReason: row.pausedReason || undefined,
|
|
userPaused: row.userPaused ? true : undefined,
|
|
baseBranch: row.baseBranch || undefined,
|
|
executionStartBranch: row.executionStartBranch || undefined,
|
|
branch: row.branch || undefined,
|
|
autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1,
|
|
baseCommitSha: row.baseCommitSha || undefined,
|
|
scopeOverride: row.scopeOverride ? true : undefined,
|
|
scopeOverrideReason: row.scopeOverrideReason || undefined,
|
|
scopeAutoWiden: fromJson<string[]>(row.scopeAutoWiden) ?? [],
|
|
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,
|
|
resumeLimboCount: row.resumeLimboCount ?? undefined,
|
|
resumeLimboTipSha: row.resumeLimboTipSha || undefined,
|
|
resumeLimboStepSignature: row.resumeLimboStepSignature || 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,
|
|
mergeTransientRetryCount: row.mergeTransientRetryCount ?? 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,
|
|
allowResurrection: row.allowResurrection ? true : undefined,
|
|
};
|
|
}
|
|
|
|
private rowToBranchGroup(row: BranchGroupRow): BranchGroup {
|
|
return {
|
|
id: row.id,
|
|
sourceType: row.sourceType,
|
|
sourceId: row.sourceId,
|
|
branchName: row.branchName,
|
|
worktreePath: row.worktreePath ?? undefined,
|
|
autoMerge: Boolean(row.autoMerge),
|
|
prState: row.prState,
|
|
prUrl: row.prUrl ?? undefined,
|
|
prNumber: row.prNumber ?? undefined,
|
|
status: row.status,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
closedAt: row.closedAt ?? undefined,
|
|
};
|
|
}
|
|
|
|
private generateBranchGroupId(): string {
|
|
const timestamp = Date.now().toString(36).toUpperCase();
|
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
return `BG-${timestamp}-${random}`;
|
|
}
|
|
|
|
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,
|
|
branchContext: entry.branchContext,
|
|
autoMerge: entry.autoMerge,
|
|
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,
|
|
branchContext: task.branchContext,
|
|
autoMerge: task.autoMerge,
|
|
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 rowToGoalCitation(row: GoalCitationRow): GoalCitation {
|
|
return {
|
|
id: row.id,
|
|
goalId: row.goalId,
|
|
agentId: row.agentId,
|
|
...(row.taskId ? { taskId: row.taskId } : {}),
|
|
surface: row.surface,
|
|
sourceRef: row.sourceRef,
|
|
snippet: row.snippet,
|
|
timestamp: row.timestamp,
|
|
};
|
|
}
|
|
|
|
recordGoalCitations(inputs: GoalCitationInput[]): GoalCitation[] {
|
|
if (inputs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const stmt = this.db.prepare(`
|
|
INSERT OR IGNORE INTO goal_citations (goalId, agentId, taskId, surface, sourceRef, snippet, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING *
|
|
`);
|
|
|
|
const inserted: GoalCitation[] = [];
|
|
this.db.transaction(() => {
|
|
for (const input of inputs) {
|
|
const row = stmt.get(
|
|
input.goalId,
|
|
input.agentId,
|
|
input.taskId ?? null,
|
|
input.surface,
|
|
input.sourceRef,
|
|
input.snippet,
|
|
input.timestamp ?? now,
|
|
) as GoalCitationRow | undefined;
|
|
if (row) {
|
|
inserted.push(this.rowToGoalCitation(row));
|
|
}
|
|
}
|
|
if (inserted.length > 0) {
|
|
this.db.bumpLastModified();
|
|
}
|
|
});
|
|
|
|
return inserted;
|
|
}
|
|
|
|
listGoalCitations(filter: GoalCitationFilter = {}): GoalCitation[] {
|
|
const clauses: string[] = [];
|
|
const params: Array<string | number> = [];
|
|
|
|
if (filter.goalId) {
|
|
clauses.push("goalId = ?");
|
|
params.push(filter.goalId);
|
|
}
|
|
if (filter.agentId) {
|
|
clauses.push("agentId = ?");
|
|
params.push(filter.agentId);
|
|
}
|
|
if (filter.taskId) {
|
|
clauses.push("taskId = ?");
|
|
params.push(filter.taskId);
|
|
}
|
|
if (filter.surface) {
|
|
clauses.push("surface = ?");
|
|
params.push(filter.surface);
|
|
}
|
|
if (filter.startTime) {
|
|
clauses.push("timestamp >= ?");
|
|
params.push(filter.startTime);
|
|
}
|
|
if (filter.endTime) {
|
|
clauses.push("timestamp <= ?");
|
|
params.push(filter.endTime);
|
|
}
|
|
|
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
const limit = Math.max(1, Math.min(filter.limit ?? 200, 1000));
|
|
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT * FROM goal_citations ${where} ORDER BY timestamp DESC, id DESC LIMIT ?`,
|
|
)
|
|
.all(...params, limit) as GoalCitationRow[];
|
|
|
|
return rows.map((row) => this.rowToGoalCitation(row));
|
|
}
|
|
|
|
private scanAndRecordCitations(
|
|
text: string,
|
|
surface: GoalCitationSurface,
|
|
sourceRef: string,
|
|
agentId: string,
|
|
taskId?: string,
|
|
timestamp?: string,
|
|
): GoalCitationInput[] {
|
|
const matches = extractGoalCitations(text);
|
|
if (matches.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return matches.map((match) => ({
|
|
goalId: match.goalId,
|
|
agentId,
|
|
...(taskId ? { taskId } : {}),
|
|
surface,
|
|
sourceRef,
|
|
snippet: buildSnippet(text, match.index),
|
|
...(timestamp ? { timestamp } : {}),
|
|
}));
|
|
}
|
|
|
|
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", "autoMerge", "executionStartBranch", "baseCommitSha",
|
|
"modelPresetId", "modelProvider", "modelId",
|
|
"validatorModelProvider", "validatorModelId",
|
|
"planningModelProvider", "planningModelId",
|
|
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "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", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
|
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
|
// `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", "autoMerge", "executionStartBranch", "baseCommitSha",
|
|
"modelPresetId", "modelProvider", "modelId",
|
|
"validatorModelProvider", "validatorModelId",
|
|
"planningModelProvider", "planningModelId",
|
|
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "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", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
|
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
|
];
|
|
|
|
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.pausedReason ?? null,
|
|
task.userPaused ? 1 : 0,
|
|
task.baseBranch ?? null,
|
|
task.branch ?? null,
|
|
task.autoMerge === undefined ? null : (task.autoMerge ? 1 : 0),
|
|
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.resumeLimboCount ?? 0,
|
|
task.resumeLimboTipSha ?? null,
|
|
task.resumeLimboStepSignature ?? null,
|
|
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.mergeTransientRetryCount ?? 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,
|
|
task.autoMerge === undefined ? null : task.autoMerge ? 1 : 0,
|
|
toJson(task.enabledWorkflowSteps || []),
|
|
toJson(task.modifiedFiles || []),
|
|
task.missionId ?? null,
|
|
task.sliceId ?? null,
|
|
task.scopeOverride ? 1 : null,
|
|
task.scopeOverrideReason ?? null,
|
|
toJson(task.scopeAutoWiden || []),
|
|
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,
|
|
task.allowResurrection ? 1 : 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Insert a brand-new task row. Create paths must use this so SQLite raises on
|
|
* duplicate IDs instead of silently rewriting the existing row.
|
|
*/
|
|
private insertTask(task: Task): void {
|
|
const values = this.getTaskPersistValues(task);
|
|
const placeholders = values.map(() => "?").join(", ");
|
|
this.db.prepare(`
|
|
INSERT INTO tasks (
|
|
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
|
|
worktree, blockedBy, overlapBlockedBy, paused, pausedReason, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
|
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
|
workflowStepRetries, stuckKillCount, resumeLimboCount, resumeLimboTipSha, resumeLimboStepSignature, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, 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, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
|
) 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, pausedReason, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
|
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
|
workflowStepRetries, stuckKillCount, resumeLimboCount, resumeLimboTipSha, resumeLimboStepSignature, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, 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, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
|
) 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,
|
|
pausedReason = excluded.pausedReason,
|
|
userPaused = excluded.userPaused,
|
|
baseBranch = excluded.baseBranch,
|
|
branch = excluded.branch,
|
|
autoMerge = excluded.autoMerge,
|
|
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,
|
|
resumeLimboCount = excluded.resumeLimboCount,
|
|
resumeLimboTipSha = excluded.resumeLimboTipSha,
|
|
resumeLimboStepSignature = excluded.resumeLimboStepSignature,
|
|
postReviewFixCount = excluded.postReviewFixCount,
|
|
recoveryRetryCount = excluded.recoveryRetryCount,
|
|
taskDoneRetryCount = excluded.taskDoneRetryCount,
|
|
worktreeSessionRetryCount = excluded.worktreeSessionRetryCount,
|
|
completionHandoffLimboRecoveryCount = excluded.completionHandoffLimboRecoveryCount,
|
|
verificationFailureCount = excluded.verificationFailureCount,
|
|
mergeConflictBounceCount = excluded.mergeConflictBounceCount,
|
|
mergeAuditBounceCount = excluded.mergeAuditBounceCount,
|
|
mergeTransientRetryCount = excluded.mergeTransientRetryCount,
|
|
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,
|
|
autoMerge = excluded.autoMerge,
|
|
enabledWorkflowSteps = excluded.enabledWorkflowSteps,
|
|
modifiedFiles = excluded.modifiedFiles,
|
|
missionId = excluded.missionId,
|
|
sliceId = excluded.sliceId,
|
|
scopeOverride = excluded.scopeOverride,
|
|
scopeOverrideReason = excluded.scopeOverrideReason,
|
|
scopeAutoWiden = excluded.scopeAutoWiden,
|
|
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,
|
|
allowResurrection = excluded.allowResurrection
|
|
`).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 maybeResolveTombstonedTaskId(
|
|
id: string,
|
|
input: Pick<TaskCreateInput, "forceResurrect">,
|
|
operation: "createTask" | "duplicateTask" | "refineTask",
|
|
): void {
|
|
const existing = this.readTaskFromDb(id, { includeDeleted: true });
|
|
if (!existing?.deletedAt) return;
|
|
|
|
const allowResurrection = existing.allowResurrection === true;
|
|
if (input.forceResurrect === true || allowResurrection) {
|
|
this.purgeTaskWorkflowSelectionRows(id);
|
|
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
|
|
this.db.bumpLastModified();
|
|
return;
|
|
}
|
|
|
|
storeLog.warn(`[tombstone-resurrection-blocked] ${id} deletedAt=${existing.deletedAt}`);
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
domain: "database",
|
|
mutationType: "task:resurrection-blocked",
|
|
target: id,
|
|
metadata: {
|
|
id,
|
|
deletedAt: existing.deletedAt,
|
|
allowResurrection,
|
|
operation,
|
|
},
|
|
});
|
|
|
|
throw new TombstonedTaskResurrectionError(id, existing.deletedAt, allowResurrection);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private findLiveLineageChildren(id: string): string[] {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT id FROM tasks WHERE sourceParentTaskId = ? AND id != ? AND "column" != 'archived' AND ${TaskStore.ACTIVE_TASKS_WHERE}`,
|
|
)
|
|
.all(id, id) as Array<{ id: string }>;
|
|
|
|
return rows.map((row) => row.id);
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
if (this.suppressActivityLogForPollingEmit) return;
|
|
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) => {
|
|
if (this.suppressActivityLogForPollingEmit) return;
|
|
if (data.from === data.to) return;
|
|
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 (this.suppressActivityLogForPollingEmit) return;
|
|
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) => {
|
|
if (this.suppressActivityLogForPollingEmit) return;
|
|
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!();
|
|
}
|
|
});
|
|
}
|
|
|
|
private getTaskIdFromDir(dir: string): string {
|
|
const parts = dir.replace(/\\/g, "/").split("/");
|
|
return parts[parts.length - 1];
|
|
}
|
|
|
|
private insertRunAuditEventRow(input: Omit<RunAuditEventInput, "agentId" | "runId"> & { agentId?: string; runId?: string }): void {
|
|
const eventId = randomUUID();
|
|
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
const agentId = input.agentId ?? "store";
|
|
const runId = input.runId ?? `store:${input.mutationType}:${input.taskId ?? input.target}:${eventId}`;
|
|
this.db.prepare(`
|
|
INSERT INTO runAuditEvents (
|
|
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
eventId,
|
|
timestamp,
|
|
input.taskId ?? null,
|
|
agentId,
|
|
runId,
|
|
input.domain,
|
|
input.mutationType,
|
|
input.target,
|
|
toJsonNullable(input.metadata),
|
|
);
|
|
}
|
|
|
|
private getSoftDeletedWriteConflict(id: string, task: Task): string | undefined {
|
|
const existing = this.readTaskFromDb(id, { includeDeleted: true });
|
|
if (!existing?.deletedAt || task.deletedAt !== undefined) {
|
|
return undefined;
|
|
}
|
|
return existing.deletedAt;
|
|
}
|
|
|
|
private throwSoftDeletedWriteBlocked(
|
|
id: string,
|
|
deletedAt: string,
|
|
operation: string,
|
|
auditInput?: {
|
|
agentId?: string;
|
|
runId?: string;
|
|
timestamp?: string;
|
|
},
|
|
): never {
|
|
storeLog.warn(`[soft-delete-resurrection-blocked] refusing ${operation} for ${id}`, {
|
|
id,
|
|
deletedAt,
|
|
operation,
|
|
});
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: auditInput?.agentId,
|
|
runId: auditInput?.runId,
|
|
timestamp: auditInput?.timestamp,
|
|
domain: "database",
|
|
mutationType: "task:resurrection-blocked",
|
|
target: id,
|
|
metadata: {
|
|
id,
|
|
deletedAt,
|
|
operation,
|
|
},
|
|
});
|
|
throw new TaskDeletedError(id, deletedAt);
|
|
}
|
|
|
|
/**
|
|
* Read a task from SQLite by ID (extracted from dir path for backward compat).
|
|
* Falls back to file-based reading only when no DB row exists at all.
|
|
*/
|
|
private async readTaskJson(dir: string): Promise<Task> {
|
|
const id = this.getTaskIdFromDir(dir);
|
|
|
|
const task = this.readTaskFromDb(id);
|
|
if (task) return task;
|
|
|
|
const deletedTask = this.readTaskFromDb(id, { includeDeleted: true });
|
|
if (deletedTask?.deletedAt) {
|
|
throw new TaskDeletedError(id, deletedTask.deletedAt);
|
|
}
|
|
|
|
// Fallback to file-based reading (for legacy compatibility when no DB row exists).
|
|
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> {
|
|
const id = this.getTaskIdFromDir(dir);
|
|
let deletedAt: string | undefined;
|
|
this.db.transactionImmediate(() => {
|
|
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
|
if (deletedAt) return;
|
|
this.insertTaskWithFtsRecovery(task, operation);
|
|
});
|
|
if (deletedAt) {
|
|
this.throwSoftDeletedWriteBlocked(id, deletedAt, 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> {
|
|
const id = this.getTaskIdFromDir(dir);
|
|
let deletedAt: string | undefined;
|
|
this.db.transactionImmediate(() => {
|
|
// Soft-delete/restore state is written via direct SQL paths (deleteTask and
|
|
// future restore flows), so stale task.json upserts must never clear deletedAt.
|
|
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
|
if (deletedAt) return;
|
|
this.upsertTaskWithFtsRecovery(task);
|
|
});
|
|
if (deletedAt) {
|
|
this.throwSoftDeletedWriteBlocked(id, deletedAt, "atomicWriteTaskJson");
|
|
}
|
|
// 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> {
|
|
const id = this.getTaskIdFromDir(dir);
|
|
let deletedAt: string | undefined;
|
|
this.db.transactionImmediate(() => {
|
|
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
|
if (deletedAt) return;
|
|
|
|
// Upsert the task
|
|
this.upsertTaskWithFtsRecovery(task);
|
|
|
|
// Optionally record the audit event in the same transaction
|
|
if (auditInput) {
|
|
this.insertRunAuditEventRow(auditInput);
|
|
}
|
|
});
|
|
if (deletedAt) {
|
|
this.throwSoftDeletedWriteBlocked(id, deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", {
|
|
agentId: auditInput?.agentId,
|
|
runId: auditInput?.runId,
|
|
timestamp: auditInput?.timestamp,
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Validate the optional UI locale at the write boundary: drop unrecognized
|
|
// values rather than persisting junk into settings.json. Runtime consumers
|
|
// also guard via isLocale, but the contract is `language?: Locale`.
|
|
// `null` passes through intact — GlobalSettingsStore treats null as
|
|
// "delete this key", which reverts the language to runtime auto-detect.
|
|
if ("language" in globalPatch) {
|
|
const rawLanguage = (globalPatch as Record<string, unknown>)["language"];
|
|
if (rawLanguage !== null) {
|
|
const validatedLanguage = validateLocale(rawLanguage);
|
|
if (validatedLanguage === undefined) {
|
|
delete (globalPatch as Record<string, unknown>)["language"];
|
|
} else {
|
|
globalPatch.language = validatedLanguage;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async buildActiveTaskDependencyLookup(overrides?: Map<string, readonly string[]>): Promise<Map<string, readonly string[]>> {
|
|
const tasks = await this.listTasks({ includeArchived: false });
|
|
const lookup = new Map<string, readonly string[]>();
|
|
for (const task of tasks) {
|
|
lookup.set(task.id, task.dependencies ?? []);
|
|
}
|
|
if (overrides) {
|
|
for (const [taskId, deps] of overrides.entries()) {
|
|
lookup.set(taskId, deps);
|
|
}
|
|
}
|
|
return lookup;
|
|
}
|
|
|
|
private recordDependencyCycleRejectedAudit(
|
|
taskId: string,
|
|
cyclePath: readonly string[],
|
|
source: "createTask" | "createTaskWithReservedId" | "updateTask" | "replication",
|
|
): void {
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: source === "replication" ? "task:dependency-cycle-rejected-replication" : "task:dependency-cycle-rejected",
|
|
target: taskId,
|
|
metadata: { taskId, cyclePath, source },
|
|
});
|
|
}
|
|
|
|
private async assertNoDependencyCycle(
|
|
taskId: string,
|
|
dependencies: readonly string[],
|
|
source: "createTask" | "createTaskWithReservedId" | "updateTask" | "replication",
|
|
overrides?: Map<string, readonly string[]>,
|
|
): Promise<void> {
|
|
if (dependencies.length === 0 && !overrides) return;
|
|
const lookup = await this.buildActiveTaskDependencyLookup(overrides);
|
|
const cyclePath = detectDependencyCycle(taskId, dependencies, (candidateId) => lookup.get(candidateId));
|
|
if (!cyclePath) return;
|
|
this.recordDependencyCycleRejectedAudit(taskId, cyclePath, source);
|
|
if (source === "replication") {
|
|
storeLog.warn("Skipping replicated task create due to dependency cycle", { taskId, cyclePath });
|
|
return;
|
|
}
|
|
throw new DependencyCycleError(taskId, cyclePath);
|
|
}
|
|
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;
|
|
|
|
// When a project default workflow is configured, new tasks inherit it
|
|
// (compiled to steps) ahead of the legacy default-on step behavior.
|
|
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
|
|
if (input.enabledWorkflowSteps === undefined) {
|
|
try {
|
|
const inherited = await this.materializeDefaultWorkflowSteps();
|
|
if (inherited) {
|
|
resolvedWorkflowSteps = inherited.stepIds;
|
|
pendingWorkflowSelection = inherited;
|
|
}
|
|
} catch (err) {
|
|
storeLog.warn("Failed to apply default workflow during task creation; falling back to default-on steps", {
|
|
phase: "createTask:default-workflow",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
|
|
if (resolvedWorkflowSteps === 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;
|
|
}
|
|
|
|
let task: Task;
|
|
try {
|
|
task = await this.createTaskWithDistributedReservation(input, {
|
|
createTaskWithId: async (taskId) => {
|
|
await this.assertNoDependencyCycle(taskId, input.dependencies ?? [], "createTask");
|
|
return this._createTaskInternal(
|
|
input,
|
|
title,
|
|
resolvedWorkflowSteps,
|
|
taskId,
|
|
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization },
|
|
);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
// The task row was never created, so any default-workflow steps we
|
|
// materialized above would orphan with no task/selection pointing at them.
|
|
this.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
|
|
throw err;
|
|
}
|
|
|
|
// Record the inherited workflow selection now that the task row exists.
|
|
if (pendingWorkflowSelection) {
|
|
try {
|
|
this.writeTaskWorkflowSelection(task.id, pendingWorkflowSelection.workflowId, pendingWorkflowSelection.stepIds);
|
|
} catch (err) {
|
|
storeLog.warn("Failed to record inherited workflow selection", {
|
|
taskId: task.id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
|
|
|
|
this.maybeResolveTombstonedTaskId(id, input, "createTask");
|
|
this.assertTaskIdAvailable(id);
|
|
|
|
const title = input.title?.trim() || undefined;
|
|
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
|
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
|
: undefined;
|
|
|
|
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
|
|
if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) {
|
|
// Mirror createTask: a configured project default workflow takes
|
|
// precedence over legacy default-on steps on this creation path too.
|
|
try {
|
|
const inherited = await this.materializeDefaultWorkflowSteps();
|
|
if (inherited) {
|
|
resolvedWorkflowSteps = inherited.stepIds;
|
|
pendingWorkflowSelection = inherited;
|
|
}
|
|
} catch (err) {
|
|
storeLog.warn("Failed to apply default workflow during reserved task creation; falling back to default-on steps", {
|
|
phase: "createTaskWithReservedId:default-workflow",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
|
|
if (resolvedWorkflowSteps === 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 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;
|
|
}
|
|
|
|
let createdTask: Task;
|
|
try {
|
|
createdTask = await this._createTaskInternal(input, title, resolvedWorkflowSteps, id, {
|
|
createdAt: options.createdAt,
|
|
updatedAt: options.updatedAt,
|
|
promptOverride: options.prompt,
|
|
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
|
|
});
|
|
} catch (err) {
|
|
// The task row was never created, so any default-workflow steps we
|
|
// materialized above would orphan with no task/selection pointing at them.
|
|
this.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
|
|
throw err;
|
|
}
|
|
|
|
// Record the inherited workflow selection now that the task row exists.
|
|
if (pendingWorkflowSelection) {
|
|
try {
|
|
this.writeTaskWorkflowSelection(createdTask.id, pendingWorkflowSelection.workflowId, pendingWorkflowSelection.stepIds);
|
|
} catch (err) {
|
|
storeLog.warn("Failed to record inherited workflow selection", {
|
|
taskId: createdTask.id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return createdTask;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (payload.input.dependencies?.includes(payload.taskId)) {
|
|
this.recordDependencyCycleRejectedAudit(payload.taskId, [payload.taskId, payload.taskId], "replication");
|
|
storeLog.warn("Skipping replicated task create due to self dependency", { taskId: payload.taskId });
|
|
return { task: payload.input as Task, applied: false };
|
|
}
|
|
|
|
const lookup = await this.buildActiveTaskDependencyLookup(new Map([[payload.taskId, payload.input.dependencies ?? []]]));
|
|
const replicationCycle = detectDependencyCycle(payload.taskId, payload.input.dependencies ?? [], (candidateId) => lookup.get(candidateId));
|
|
if (replicationCycle) {
|
|
this.recordDependencyCycleRejectedAudit(payload.taskId, replicationCycle, "replication");
|
|
storeLog.warn("Skipping replicated task create due to dependency cycle", { taskId: payload.taskId, cyclePath: replicationCycle });
|
|
return { task: payload.input as Task, applied: false };
|
|
}
|
|
|
|
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,
|
|
autoMerge: input.autoMerge,
|
|
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.maybeResolveTombstonedTaskId(id, input, "createTask");
|
|
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.emitTaskLifecycleEventSafely("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;
|
|
const sourceParentTaskId = task.sourceParentTaskId ?? null;
|
|
// Need at least one provenance handle to scope the dedup check.
|
|
if (!sourceAgentId && !sourceParentTaskId) return;
|
|
|
|
try {
|
|
const nowMs = Date.now();
|
|
const recent = (await this.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
|
|
if (candidate.id === task.id) return false;
|
|
const createdMs = Date.parse(candidate.createdAt);
|
|
if (Number.isNaN(createdMs)) return false;
|
|
if (createdMs < nowMs - 24 * 60 * 60 * 1000) return false;
|
|
const agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
|
|
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
|
|
return agentMatch || parentMatch;
|
|
});
|
|
|
|
const settings = await this.getSettings();
|
|
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
|
|
let tombstonedCandidates: Array<{
|
|
id: string;
|
|
title: string | null;
|
|
description: string;
|
|
column: Column;
|
|
createdAt: string;
|
|
sourceAgentId: string | null;
|
|
deletedAt: string;
|
|
allowResurrection: number | null;
|
|
}> = [];
|
|
|
|
if (stickyWindowDays > 0) {
|
|
try {
|
|
const cutoffIso = new Date(nowMs - stickyWindowDays * 24 * 60 * 60 * 1000).toISOString();
|
|
tombstonedCandidates = this.db.prepare(`
|
|
SELECT id, title, description, "column", createdAt, sourceAgentId, deletedAt, allowResurrection
|
|
FROM tasks
|
|
WHERE deletedAt IS NOT NULL
|
|
AND deletedAt >= ?
|
|
AND sourceAgentId = ?
|
|
AND id != ?
|
|
`).all(cutoffIso, sourceAgentId, task.id) as typeof tombstonedCandidates;
|
|
} catch (error) {
|
|
storeLog.warn(`FN-5233 tombstone candidate widening failed open for ${task.id}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
const matches = findSameAgentDuplicates(
|
|
{
|
|
title: input.title ?? task.title,
|
|
description: input.description,
|
|
sourceParentTaskId,
|
|
},
|
|
[
|
|
...recent.map((candidate) => ({
|
|
id: candidate.id,
|
|
title: candidate.title ?? "",
|
|
description: candidate.description,
|
|
column: candidate.column,
|
|
createdAt: Date.parse(candidate.createdAt),
|
|
sourceAgentId: candidate.sourceAgentId ?? null,
|
|
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
|
|
tombstoned: false,
|
|
})),
|
|
...tombstonedCandidates.map((candidate) => ({
|
|
id: candidate.id,
|
|
title: candidate.title ?? "",
|
|
description: candidate.description,
|
|
column: "todo" as Column,
|
|
createdAt: Date.parse(candidate.createdAt),
|
|
sourceAgentId: candidate.sourceAgentId,
|
|
sourceParentTaskId: null,
|
|
tombstoned: true,
|
|
deletedAt: candidate.deletedAt,
|
|
allowResurrection: candidate.allowResurrection === 1,
|
|
})),
|
|
],
|
|
{ nowMs, sourceAgentId },
|
|
);
|
|
|
|
if (matches.length === 0) return;
|
|
|
|
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
|
|
if (tombstonedMatch?.deletedAt) {
|
|
this.insertRunAuditEventRow({
|
|
taskId: task.id,
|
|
domain: "database",
|
|
mutationType: "intake:resurrection-blocked",
|
|
target: task.id,
|
|
metadata: {
|
|
matchedTaskId: tombstonedMatch.id,
|
|
score: tombstonedMatch.score,
|
|
tombstoneDeletedAt: tombstonedMatch.deletedAt,
|
|
stickyWindowDays,
|
|
},
|
|
});
|
|
if (this.isWatching) this.taskCache.delete(task.id);
|
|
this.deleteTaskById(task.id);
|
|
const { rm } = await import("node:fs/promises");
|
|
const taskDir = this.taskDir(task.id);
|
|
if (existsSync(taskDir)) {
|
|
await rm(taskDir, { recursive: true, force: true });
|
|
}
|
|
throw new TombstonedTaskResurrectionError(
|
|
tombstonedMatch.id,
|
|
tombstonedMatch.deletedAt,
|
|
tombstonedMatch.allowResurrection === true,
|
|
);
|
|
}
|
|
|
|
const siblingTaskIds = matches.filter((match) => !match.tombstoned).map((match) => match.id);
|
|
if (siblingTaskIds.length === 0) return;
|
|
const scores = Object.fromEntries(matches.filter((match) => !match.tombstoned).map((match) => [match.id, match.score]));
|
|
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
|
|
task.column = "archived";
|
|
} catch (error) {
|
|
if (error instanceof TombstonedTaskResurrectionError) {
|
|
throw 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.maybeResolveTombstonedTaskId(newId, {}, "duplicateTask");
|
|
this.assertTaskIdAvailable(newId);
|
|
|
|
const newDir = this.taskDir(newId);
|
|
await this.atomicCreateTaskJson(newDir, newTask, "duplicateTask");
|
|
const sanitizedPrompt = sanitizeFileScopeInPromptContent(sourceTask.prompt);
|
|
if (sanitizedPrompt.dropped.length > 0) {
|
|
storeLog.log(`[file-scope-sanitize] duplicate ${newId} from ${id}: dropped=[${sanitizedPrompt.dropped.join(",")}]`);
|
|
}
|
|
await mkdir(newDir, { recursive: true });
|
|
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
|
|
|
|
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 sourceGithubLinked = sourceTask.githubTracking?.enabled === true || Boolean(sourceTask.githubTracking?.issue);
|
|
// FN-5780: refinement should inherit source linking intent so unlinked tasks stay opted out from auto-create defaults.
|
|
const refinementGithubTracking = sourceGithubLinked
|
|
? {
|
|
enabled: true,
|
|
...(sourceTask.githubTracking?.repoOverride
|
|
? { repoOverride: sourceTask.githubTracking.repoOverride }
|
|
: {}),
|
|
}
|
|
: { enabled: false };
|
|
|
|
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,
|
|
githubTracking: refinementGithubTracking,
|
|
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.maybeResolveTombstonedTaskId(newId, {}, "refineTask");
|
|
this.assertTaskIdAvailable(newId);
|
|
|
|
const newDir = this.taskDir(newId);
|
|
await this.atomicCreateTaskJson(newDir, newTask, "refineTask");
|
|
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
|
|
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
|
|
await mkdir(newDir, { recursive: true });
|
|
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
|
|
|
|
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 };
|
|
});
|
|
}
|
|
|
|
createBranchGroup(input: BranchGroupCreateInput): BranchGroup {
|
|
// Fix #11: reject injection-shaped branch names at the persistence boundary
|
|
// so they can never reach a downstream git/shell sink (coordinator, merger).
|
|
validateBranchGroupBranchName(input.branchName);
|
|
const now = Date.now();
|
|
const id = this.generateBranchGroupId();
|
|
this.db.prepare(`
|
|
INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
input.sourceType,
|
|
input.sourceId,
|
|
input.branchName,
|
|
input.worktreePath ?? null,
|
|
input.autoMerge ? 1 : 0,
|
|
input.prState ?? "none",
|
|
input.prUrl ?? null,
|
|
input.prNumber ?? null,
|
|
input.status ?? "open",
|
|
now,
|
|
now,
|
|
input.closedAt ?? null,
|
|
);
|
|
this.db.bumpLastModified();
|
|
return this.getBranchGroup(id)!;
|
|
}
|
|
|
|
getBranchGroup(id: string): BranchGroup | null {
|
|
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE id = ?`).get(id) as BranchGroupRow | undefined;
|
|
return row ? this.rowToBranchGroup(row) : null;
|
|
}
|
|
|
|
getBranchGroupBySource(sourceType: BranchGroup["sourceType"], sourceId: string): BranchGroup | null {
|
|
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE sourceType = ? AND sourceId = ?`).get(sourceType, sourceId) as BranchGroupRow | undefined;
|
|
return row ? this.rowToBranchGroup(row) : null;
|
|
}
|
|
|
|
getBranchGroupByBranchName(branchName: string): BranchGroup | null {
|
|
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE branchName = ? AND status = 'open' ORDER BY createdAt DESC LIMIT 1`).get(branchName) as BranchGroupRow | undefined;
|
|
return row ? this.rowToBranchGroup(row) : null;
|
|
}
|
|
|
|
ensureBranchGroupForSource(
|
|
sourceType: BranchGroup["sourceType"],
|
|
sourceId: string,
|
|
init: Omit<BranchGroupCreateInput, "sourceType" | "sourceId">,
|
|
): BranchGroup {
|
|
const existing = this.getBranchGroupBySource(sourceType, sourceId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
// `branch_groups.branchName` is globally UNIQUE — a branch is represented by
|
|
// exactly one open group. If another source already owns an open group for
|
|
// this branch, reuse it rather than calling createBranchGroup and violating
|
|
// the UNIQUE constraint. Without this, two missions whose shared base resolves
|
|
// to the same branch (e.g. "main") collide: the throw escapes triageFeature
|
|
// and is swallowed by its callers, silently stranding "defined" features.
|
|
const existingByBranch = this.getBranchGroupByBranchName(init.branchName);
|
|
if (existingByBranch) {
|
|
return existingByBranch;
|
|
}
|
|
|
|
return this.createBranchGroup({
|
|
sourceType,
|
|
sourceId,
|
|
...init,
|
|
});
|
|
}
|
|
|
|
listBranchGroups(options?: { status?: BranchGroup["status"] }): BranchGroup[] {
|
|
const rows = options?.status
|
|
? this.db.prepare(`SELECT * FROM branch_groups WHERE status = ? ORDER BY createdAt ASC`).all(options.status)
|
|
: this.db.prepare(`SELECT * FROM branch_groups ORDER BY createdAt ASC`).all();
|
|
return (rows as BranchGroupRow[]).map((row) => this.rowToBranchGroup(row));
|
|
}
|
|
|
|
updateBranchGroup(id: string, patch: BranchGroupUpdate): BranchGroup {
|
|
const current = this.getBranchGroup(id);
|
|
if (!current) {
|
|
throw new Error(`Branch group ${id} not found`);
|
|
}
|
|
// Fix #11: a rename must reject injection-shaped branch names at the same
|
|
// persistence boundary as createBranchGroup, otherwise a crafted ref could
|
|
// still reach the downstream git/PR flow via an update.
|
|
if (patch.branchName !== undefined) {
|
|
validateBranchGroupBranchName(patch.branchName);
|
|
}
|
|
const nextStatus = patch.status ?? current.status;
|
|
const now = Date.now();
|
|
const nextClosedAt = patch.closedAt === null
|
|
? null
|
|
: patch.closedAt ?? (nextStatus !== "open" && current.status === "open" ? now : current.closedAt ?? null);
|
|
|
|
this.db.prepare(`
|
|
UPDATE branch_groups
|
|
SET sourceId = ?, branchName = ?, worktreePath = ?, autoMerge = ?, prState = ?, prUrl = ?, prNumber = ?, status = ?, updatedAt = ?, closedAt = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
patch.sourceId ?? current.sourceId,
|
|
patch.branchName ?? current.branchName,
|
|
patch.worktreePath === null ? null : (patch.worktreePath ?? current.worktreePath ?? null),
|
|
patch.autoMerge === undefined ? (current.autoMerge ? 1 : 0) : (patch.autoMerge ? 1 : 0),
|
|
patch.prState ?? current.prState,
|
|
patch.prUrl === null ? null : (patch.prUrl ?? current.prUrl ?? null),
|
|
patch.prNumber === null ? null : (patch.prNumber ?? current.prNumber ?? null),
|
|
nextStatus,
|
|
now,
|
|
nextClosedAt,
|
|
id,
|
|
);
|
|
this.db.bumpLastModified();
|
|
return this.getBranchGroup(id)!;
|
|
}
|
|
|
|
async setTaskBranchGroup(
|
|
taskId: string,
|
|
branchGroupId: string | null,
|
|
options?: { assignmentMode?: TaskBranchAssignmentMode },
|
|
): Promise<void> {
|
|
await this.withTaskLock(taskId, async () => {
|
|
const dir = this.taskDir(taskId);
|
|
const task = await this.readTaskJson(dir);
|
|
let branchContext: Task["branchContext"];
|
|
|
|
if (branchGroupId) {
|
|
const group = this.getBranchGroup(branchGroupId);
|
|
if (!group) {
|
|
throw new Error(`Branch group ${branchGroupId} not found`);
|
|
}
|
|
// Carry the group's actual assignment intent. The BranchGroup row does not
|
|
// persist an assignment mode, so prefer an explicit caller-provided mode,
|
|
// then preserve any existing branchContext.assignmentMode, and only fall
|
|
// back to "shared" when nothing else is known.
|
|
branchContext = {
|
|
groupId: group.id,
|
|
source: group.sourceType,
|
|
assignmentMode: options?.assignmentMode ?? task.branchContext?.assignmentMode ?? "shared",
|
|
};
|
|
}
|
|
|
|
task.branchContext = branchContext;
|
|
task.sourceMetadata = withTaskBranchContextInSourceMetadata(task.sourceMetadata, branchContext);
|
|
if (!branchContext && task.sourceMetadata) {
|
|
const nextSourceMetadata = { ...task.sourceMetadata };
|
|
delete nextSourceMetadata[TASK_BRANCH_CONTEXT_METADATA_KEY];
|
|
task.sourceMetadata = Object.keys(nextSourceMetadata).length > 0 ? nextSourceMetadata : undefined;
|
|
}
|
|
task.updatedAt = new Date().toISOString();
|
|
|
|
await this.atomicWriteTaskJson(dir, task);
|
|
if (this.isWatching) this.taskCache.set(taskId, { ...task });
|
|
this.emit("task:updated", task);
|
|
});
|
|
}
|
|
|
|
async listTasksByBranchGroup(groupId: string): Promise<Task[]> {
|
|
const tasks = await this.listTasks({ includeArchived: false, slim: true });
|
|
// Membership filter (incl. legacy synthetic-groupId fallback) is shared with
|
|
// the dashboard list route via `filterTasksByBranchGroup` so semantics can't
|
|
// drift between the two call sites (Fix #8/#9).
|
|
const group = this.getBranchGroup(groupId);
|
|
return filterTasksByBranchGroup(tasks, group, groupId).sort((a, b) =>
|
|
a.createdAt.localeCompare(b.createdAt),
|
|
);
|
|
}
|
|
|
|
recordBranchGroupMemberLanded(
|
|
groupId: string,
|
|
patch: { worktreePath?: string | null; status?: BranchGroup["status"] },
|
|
): BranchGroup {
|
|
return this.updateBranchGroup(groupId, {
|
|
...(patch.worktreePath !== undefined ? { worktreePath: patch.worktreePath } : {}),
|
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
});
|
|
}
|
|
|
|
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: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
if (!disableAgeStalenessHydration) {
|
|
try {
|
|
task.ageStaleness = getTaskAgeStalenessSignal(task, {
|
|
now,
|
|
thresholds: staleThresholds,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
} 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));
|
|
}
|
|
|
|
/**
|
|
* Residual B (U13/U9): per-branch progress snapshots for the given tasks,
|
|
* read from the `workflow_run_branches` table. Used to populate the optional
|
|
* additive `branchProgress` field on the board task payload so U9's parallel-
|
|
* window badge can render. Cheap and additive:
|
|
* - returns an empty map immediately when the table is empty (the common
|
|
* case — no fan-out runs in flight);
|
|
* - one query for the whole task batch (no per-card N+1);
|
|
* - returns only the LATEST run's branches per task (a card is in exactly
|
|
* one parallel window at a time — KTD-11 one-card-one-position).
|
|
* Never throws on a missing/legacy table (additive guard).
|
|
*/
|
|
getBranchProgressByTask(
|
|
taskIds: readonly string[],
|
|
): Map<string, Array<{ branchId: string; nodeId: string; status: string }>> {
|
|
const result = new Map<string, Array<{ branchId: string; nodeId: string; status: string }>>();
|
|
if (taskIds.length === 0) return result;
|
|
try {
|
|
// Skip entirely when the table has no rows (cheap existence probe).
|
|
const any = this.db
|
|
.prepare("SELECT 1 FROM workflow_run_branches LIMIT 1")
|
|
.get();
|
|
if (!any) return result;
|
|
|
|
const placeholders = taskIds.map(() => "?").join(", ");
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT b.taskId AS taskId, b.runId AS runId, b.branchId AS branchId,
|
|
b.currentNodeId AS nodeId, b.status AS status, b.updatedAt AS updatedAt
|
|
FROM workflow_run_branches b
|
|
JOIN (
|
|
SELECT taskId, MAX(updatedAt) AS latest
|
|
FROM workflow_run_branches
|
|
WHERE taskId IN (${placeholders})
|
|
GROUP BY taskId
|
|
) latest_run ON latest_run.taskId = b.taskId
|
|
WHERE b.taskId IN (${placeholders})`,
|
|
)
|
|
.all(...taskIds, ...taskIds) as Array<{
|
|
taskId: string;
|
|
runId: string;
|
|
branchId: string;
|
|
nodeId: string;
|
|
status: string;
|
|
updatedAt: string;
|
|
}>;
|
|
|
|
// Group by task; for each task keep only the branches of its most-recent
|
|
// run (the runId of the row with the latest updatedAt).
|
|
const latestRunByTask = new Map<string, string>();
|
|
for (const row of rows) {
|
|
const known = latestRunByTask.get(row.taskId);
|
|
if (!known) latestRunByTask.set(row.taskId, row.runId);
|
|
}
|
|
// Re-derive the latest runId precisely from the max-updatedAt row.
|
|
const maxByTask = new Map<string, { runId: string; updatedAt: string }>();
|
|
for (const row of rows) {
|
|
const cur = maxByTask.get(row.taskId);
|
|
if (!cur || row.updatedAt > cur.updatedAt) {
|
|
maxByTask.set(row.taskId, { runId: row.runId, updatedAt: row.updatedAt });
|
|
}
|
|
}
|
|
for (const row of rows) {
|
|
const latest = maxByTask.get(row.taskId);
|
|
if (!latest || row.runId !== latest.runId) continue;
|
|
const list = result.get(row.taskId) ?? [];
|
|
list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status });
|
|
result.set(row.taskId, list);
|
|
}
|
|
} catch {
|
|
// Legacy/missing table or query failure — degrade to no branch progress.
|
|
return new Map();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
|
|
const reconcileScanLimit = 200;
|
|
const offset = Math.max(0, options?.offset ?? 0);
|
|
const limit = Math.max(0, options?.limit ?? reconcileScanLimit);
|
|
const selectClause = this.getTaskSelectClause(true);
|
|
|
|
// FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows,
|
|
// so this query intentionally bypasses ACTIVE_TASKS_WHERE.
|
|
const deletedTotal = this.db.prepare(
|
|
"SELECT COUNT(*) as count FROM tasks WHERE \"deletedAt\" IS NOT NULL AND \"githubTracking\" IS NOT NULL",
|
|
).get() as { count: number } | undefined;
|
|
const deletedCount = Number(deletedTotal?.count ?? 0);
|
|
|
|
const deletedOffset = Math.min(offset, deletedCount);
|
|
const deletedRows = this.db.prepare(
|
|
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`,
|
|
).all(limit, deletedOffset) as unknown as TaskRow[];
|
|
|
|
const deletedTasks = deletedRows.map((row) => {
|
|
const task = this.rowToTask(row);
|
|
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
|
|
task.log = [];
|
|
return task;
|
|
});
|
|
|
|
let archivedTasks: Task[] = [];
|
|
let archivedCount = 0;
|
|
try {
|
|
const archivedCandidates = this.archiveDb
|
|
.list()
|
|
.map((entry) => this.archiveEntryToTask(entry, true))
|
|
.filter((task) => Boolean(task.githubTracking));
|
|
|
|
archivedCount = archivedCandidates.length;
|
|
const archivedOffset = Math.max(0, offset - deletedCount);
|
|
const remainingLimit = Math.max(0, limit - deletedTasks.length);
|
|
archivedTasks = remainingLimit > 0
|
|
? archivedCandidates.slice(archivedOffset, archivedOffset + remainingLimit)
|
|
: [];
|
|
} catch {
|
|
archivedTasks = [];
|
|
archivedCount = 0;
|
|
}
|
|
|
|
const totalCount = deletedCount + archivedCount;
|
|
const hasMore = offset + limit < totalCount;
|
|
return { tasks: [...deletedTasks, ...archivedTasks], hasMore };
|
|
}
|
|
|
|
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: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
if (!disableAgeStalenessHydration) {
|
|
try {
|
|
task.ageStaleness = getTaskAgeStalenessSignal(task, {
|
|
now,
|
|
thresholds: staleThresholds,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
} 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: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedReview = getStalePausedReviewSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedReviewThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.inReviewStalled = getInReviewStalledSignal(task, {
|
|
now,
|
|
thresholdMs: settings.inReviewStalledThresholdMs,
|
|
autoMerge: allowsAutoMergeProcessing(task, settings),
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
task.stalePausedTodo = getStalePausedTodoSignal(task, {
|
|
now,
|
|
thresholdMs: settings.stalePausedTodoThresholdMs,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
if (!disableAgeStalenessHydration) {
|
|
try {
|
|
task.ageStaleness = getTaskAgeStalenessSignal(task, {
|
|
now,
|
|
thresholds: staleThresholds,
|
|
engineActiveSinceMs: settings.engineActiveSinceMs,
|
|
engineActivationGraceMs: settings.engineActivationGraceMs,
|
|
});
|
|
} 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";
|
|
});
|
|
}
|
|
|
|
private async readTaskForMove(id: string): Promise<Task> {
|
|
const dir = this.taskDir(id);
|
|
try {
|
|
return await this.readTaskJson(dir);
|
|
} catch (error) {
|
|
const archived = this.archiveDb.get(id);
|
|
if (!archived) {
|
|
throw error;
|
|
}
|
|
return this.archiveEntryToTask(archived, false);
|
|
}
|
|
}
|
|
|
|
async moveTask(
|
|
id: string,
|
|
toColumn: ColumnId,
|
|
options?: MoveTaskOptions,
|
|
): Promise<Task> {
|
|
// ColumnId admits workflow-defined custom column ids (KTD-1). Both paths
|
|
// runtime-validate: flag-ON against the task's resolved workflow, flag-OFF
|
|
// via the VALID_TRANSITIONS lookup (non-legacy ids reject as before).
|
|
return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn as Column, options, { fromHandoff: false }));
|
|
}
|
|
|
|
async handoffToReview(taskId: string, opts: HandoffToReviewOptions): Promise<Task> {
|
|
return this.withTaskLock(taskId, async () => {
|
|
let task: Task;
|
|
try {
|
|
task = await this.readTaskForMove(taskId);
|
|
} catch (error) {
|
|
if (error instanceof TaskDeletedError) {
|
|
const deletedTask = this.readTaskFromDb(taskId, { includeDeleted: true });
|
|
throw new HandoffInvariantViolationError(
|
|
taskId,
|
|
deletedTask?.column ?? "todo",
|
|
`Cannot hand off ${taskId} to in-review because the task is deleted`,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (task.column === "archived" || task.deletedAt != null) {
|
|
throw new HandoffInvariantViolationError(
|
|
taskId,
|
|
task.column,
|
|
`Cannot hand off ${taskId} to in-review from ${task.column}`,
|
|
);
|
|
}
|
|
|
|
return this.moveTaskInternal(
|
|
taskId,
|
|
"in-review",
|
|
{
|
|
...opts.moveOptions,
|
|
skipMergeBlocker: true,
|
|
// KTD-9: handoff is an engine/recovery-class move; its skipMergeBlocker
|
|
// maps onto bypassGuards under the flag (identical behavior both paths).
|
|
bypassGuards: true,
|
|
},
|
|
{
|
|
fromHandoff: true,
|
|
runContext: {
|
|
runId: opts.evidence.runId,
|
|
agentId: opts.evidence.agentId,
|
|
},
|
|
ownerAgentId: opts.ownerAgentId,
|
|
evidence: opts.evidence,
|
|
now: opts.now,
|
|
},
|
|
task,
|
|
);
|
|
});
|
|
}
|
|
|
|
private async moveTaskInternal(
|
|
id: string,
|
|
toColumn: Column,
|
|
options: MoveTaskOptions | undefined,
|
|
internal: MoveTaskInternalOptions,
|
|
currentTask?: Task,
|
|
): Promise<Task> {
|
|
const dir = this.taskDir(id);
|
|
const task = currentTask ?? await this.readTaskForMove(id);
|
|
const moveSource = options?.moveSource ?? "engine";
|
|
|
|
// ── U4: flag-gated workflow-resolved transition path (KTD-8) ─────────────
|
|
// Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect
|
|
// path below runs byte-identical (proven by the characterization suite).
|
|
// Flag ON: validate against the task's resolved workflow column graph, run
|
|
// sync trait guards (unless bypassed), and route the legacy per-column side
|
|
// effects through the default-workflow trait hooks.
|
|
// `experimentalFeatures` is a global-scoped setting, so the project-only
|
|
// `getSettingsSync()` row would miss it — read merged settings (global +
|
|
// project) via getSettingsFast(). This is an async read taken before the
|
|
// lock-sensitive transaction; it does not touch the task lock.
|
|
const mergedSettingsForMove = await this.getSettingsFast();
|
|
const useWorkflow = isWorkflowColumnsEnabled(mergedSettingsForMove);
|
|
// bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker
|
|
// call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the
|
|
// capacity check is not a guard (U6 fills the enforcement; U4 leaves a
|
|
// pass-through slot). An explicit option value wins; otherwise derive it.
|
|
const bypassGuards =
|
|
options?.recoveryRehome === true ||
|
|
(options?.bypassGuards ??
|
|
(moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true));
|
|
const workflowIr: WorkflowIr | undefined = useWorkflow
|
|
? this.resolveTaskWorkflowIrSync(id)
|
|
: undefined;
|
|
|
|
if (task.column === toColumn) {
|
|
if (internal.fromHandoff && toColumn === "in-review") {
|
|
this.db.transactionImmediate(() => {
|
|
const liveRow = this.readTaskFromDb(id, { includeDeleted: true });
|
|
if (liveRow?.deletedAt) {
|
|
throw new HandoffInvariantViolationError(
|
|
id,
|
|
task.column,
|
|
`Cannot hand off ${id} to in-review because the task is deleted`,
|
|
);
|
|
}
|
|
const existing = this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id) as { 1: number } | undefined;
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
domain: "database",
|
|
mutationType: "task:move",
|
|
target: id,
|
|
metadata: {
|
|
from: task.column,
|
|
to: toColumn,
|
|
moveSource,
|
|
},
|
|
});
|
|
this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now });
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
domain: "database",
|
|
mutationType: "task:handoff",
|
|
target: id,
|
|
metadata: {
|
|
taskId: id,
|
|
fromColumn: task.column,
|
|
ownerAgentId: internal.ownerAgentId ?? null,
|
|
reason: internal.evidence?.reason,
|
|
runId: internal.runContext?.runId,
|
|
agentId: internal.runContext?.agentId,
|
|
alreadyEnqueued: Boolean(existing),
|
|
},
|
|
});
|
|
});
|
|
return task;
|
|
}
|
|
|
|
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 fromColumn = task.column;
|
|
|
|
if (useWorkflow && workflowIr) {
|
|
// ── Flag-ON validation + sync guards (typed rejections, KTD-3/R13) ─────
|
|
// 1. Target column must exist in the task's workflow → unknown-column.
|
|
if (!workflowHasColumn(workflowIr, toColumn)) {
|
|
throw new TransitionRejectionError(
|
|
makeTransitionRejection(
|
|
"unknown-column",
|
|
"transition.rejected.unknownColumn",
|
|
false,
|
|
`Column '${toColumn}' is not defined in this task's workflow`,
|
|
),
|
|
`Invalid transition: '${fromColumn}' → '${toColumn}'. Unknown column for this workflow.`,
|
|
);
|
|
}
|
|
// 2. Column-graph adjacency. For the default workflow this reproduces
|
|
// VALID_TRANSITIONS verbatim (resolveAllowedColumns); the
|
|
// transition-parity suite machine-checks the equivalence. A U5 recovery
|
|
// re-home (recoveryRehome) skips this so a stranded card can reach its
|
|
// new workflow's entry column from any current column.
|
|
const allowed = resolveAllowedColumns(workflowIr, fromColumn);
|
|
if (options?.recoveryRehome !== true && !allowed.includes(toColumn)) {
|
|
throw new TransitionRejectionError(
|
|
makeTransitionRejection(
|
|
"guard-rejected",
|
|
"transition.rejected.invalidTransition",
|
|
false,
|
|
`Valid targets: ${allowed.join(", ") || "none"}`,
|
|
),
|
|
`Invalid transition: '${fromColumn}' → '${toColumn}'. ` +
|
|
`Valid targets: ${allowed.join(", ") || "none"}`,
|
|
);
|
|
}
|
|
// 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards
|
|
// (engine/recovery moves, KTD-9). The default workflow's merge-blocker
|
|
// trait reads the same getTaskMergeBlocker.
|
|
if (!bypassGuards) {
|
|
const guardReason = evaluateMergeBlockerGuard(task, fromColumn, toColumn);
|
|
if (guardReason) {
|
|
throw new TransitionRejectionError(
|
|
makeTransitionRejection(
|
|
"merge-blocked",
|
|
"transition.rejected.mergeBlocked",
|
|
true,
|
|
guardReason,
|
|
),
|
|
`Cannot move ${id} to done: ${guardReason}`,
|
|
);
|
|
}
|
|
// 4. Plugin gate verdict re-check (U8, KTD-2). For each PLUGIN gate trait
|
|
// on the target column, consume the pre-evaluated verdict (recorded by
|
|
// the engine's trait adapter outside the lock). A blocking gate with
|
|
// no recorded `allow` verdict fails closed (typed rejection); advisory
|
|
// gates record-and-allow. Built-in gates are handled by their own
|
|
// path; this guard is the plugin gate surface only.
|
|
const registry = getTraitRegistry();
|
|
const pluginGates = resolveColumnPluginGates(
|
|
findWorkflowColumn(workflowIr, toColumn),
|
|
(tid) => registry.getTrait(tid),
|
|
);
|
|
if (pluginGates.length > 0) {
|
|
const recorded = this.consumePluginGateVerdicts(id, toColumn);
|
|
const byTrait = new Map(recorded.map((v) => [v.traitId, v]));
|
|
for (const gate of pluginGates) {
|
|
if (gate.gateMode === "advisory") continue; // record-and-allow
|
|
// Degraded (force-disabled) plugin gate: its hook impl is gone, so
|
|
// the registry resolves it to a no-op + audit warning (KTD-7). A
|
|
// degraded gate is PASSIVE — the column never blocks the card; the
|
|
// registry's warning is the audit signal. Cards remain movable.
|
|
const resolved = registry.resolveTraitHook(gate.traitId, "gate");
|
|
if (resolved.warning) continue;
|
|
const verdict = byTrait.get(gate.traitId);
|
|
// Fail closed: a blocking gate with no recorded allow verdict rejects.
|
|
if (!verdict || !verdict.allow) {
|
|
const reason =
|
|
verdict?.detail ??
|
|
(verdict
|
|
? `Gate '${gate.traitId}' did not pass`
|
|
: `Gate '${gate.traitId}' has not been evaluated for this move`);
|
|
throw new TransitionRejectionError(
|
|
makeTransitionRejection(
|
|
"merge-blocked",
|
|
"transition.rejected.gateBlocked",
|
|
true,
|
|
reason,
|
|
),
|
|
`Cannot move ${id} to '${toColumn}': ${reason}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// ── Flag-OFF legacy path (unchanged) ───────────────────────────────────
|
|
const validTargets = VALID_TRANSITIONS[task.column];
|
|
if (!validTargets.includes(toColumn)) {
|
|
throw new Error(
|
|
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
|
|
`Valid targets: ${validTargets.join(", ") || "none"}`,
|
|
);
|
|
}
|
|
|
|
if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) {
|
|
const mergeBlocker = getTaskMergeBlocker(task);
|
|
if (mergeBlocker) {
|
|
throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const settingsForInReview =
|
|
toColumn === "in-review" && task.autoMerge === undefined
|
|
? await this.getSettingsFast()
|
|
: undefined;
|
|
|
|
const movedAt = internal.now ?? new Date().toISOString();
|
|
task.column = toColumn;
|
|
task.columnMovedAt = movedAt;
|
|
task.updatedAt = movedAt;
|
|
|
|
if (useWorkflow) {
|
|
// ── Flag-ON: route the legacy per-column side effects through the
|
|
// default-workflow trait hooks (timing, reset-on-entry, abort-on-exit,
|
|
// merge.onEnter). "Moved, not duplicated" applies to this path; the
|
|
// flag-off branch below keeps the legacy inline code verbatim. ───────
|
|
const ctx: DefaultWorkflowMoveContext = {
|
|
task,
|
|
fromColumn,
|
|
toColumn,
|
|
moveSource,
|
|
bypassGuards,
|
|
movedAt,
|
|
settings: settingsForInReview,
|
|
options: {
|
|
preserveStatus: options?.preserveStatus,
|
|
preserveResumeState: options?.preserveResumeState,
|
|
preserveProgress: options?.preserveProgress,
|
|
preserveWorktree: options?.preserveWorktree,
|
|
},
|
|
resetSteps: () => this.resetAllStepsToPending(task),
|
|
};
|
|
const isReopenToTodoOrTriage =
|
|
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") &&
|
|
(toColumn === "todo" || toColumn === "triage");
|
|
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
|
|
const preserveStepProgress =
|
|
options?.preserveResumeState ||
|
|
(options?.preserveProgress === true && hasNonPendingStepProgress);
|
|
const { warnings } = applyDefaultWorkflowMoveEffects(ctx);
|
|
for (const warning of warnings) {
|
|
storeLog.warn("Default-workflow trait hook degraded to no-op", {
|
|
phase: "moveTaskInternal:workflow-hooks",
|
|
taskId: id,
|
|
...warning,
|
|
});
|
|
}
|
|
// Store-owned effects the hooks intentionally do NOT perform (filesystem /
|
|
// store-private): clearing done transient fields + prompt-checkbox reset.
|
|
if (toColumn === "done") {
|
|
this.clearDoneTransientFields(task);
|
|
}
|
|
if (isReopenToTodoOrTriage && !preserveStepProgress) {
|
|
await this.resetPromptCheckboxes(dir);
|
|
}
|
|
} else {
|
|
// ── Flag-OFF legacy inline side effects (UNCHANGED — the flag-off path) ──
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (toColumn === "done") {
|
|
this.clearDoneTransientFields(task);
|
|
}
|
|
|
|
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);
|
|
|
|
if (!options?.preserveWorktree) {
|
|
task.worktree = undefined;
|
|
}
|
|
|
|
if (!options?.preserveResumeState) {
|
|
task.executionStartedAt = undefined;
|
|
task.executionCompletedAt = undefined;
|
|
} else {
|
|
task.executionCompletedAt = undefined;
|
|
}
|
|
|
|
if (!preserveStepProgress) {
|
|
this.resetAllStepsToPending(task);
|
|
await this.resetPromptCheckboxes(dir);
|
|
}
|
|
}
|
|
|
|
if (toColumn === "in-review") {
|
|
if (task.autoMerge === undefined && settingsForInReview) {
|
|
task.autoMerge = settingsForInReview.autoMerge;
|
|
}
|
|
task.recoveryRetryCount = undefined;
|
|
task.nextRecoveryAt = undefined;
|
|
// Clear scheduler-side dispatch state: `queued`, `blockedBy`, and
|
|
// `overlapBlockedBy` are stamped while the task waits in `todo`. If
|
|
// they survive the transition into `in-review` they permanently block
|
|
// the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES).
|
|
if (task.status === "queued") {
|
|
task.status = undefined;
|
|
}
|
|
task.blockedBy = undefined;
|
|
task.overlapBlockedBy = undefined;
|
|
}
|
|
|
|
if (
|
|
(fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|
|
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage"))
|
|
) {
|
|
task.workflowStepResults = undefined;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
let deletedAt: string | undefined;
|
|
let alreadyEnqueued = false;
|
|
this.db.transactionImmediate(() => {
|
|
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
|
if (deletedAt) {
|
|
return;
|
|
}
|
|
|
|
// ── U6: in-txn capacity enforcement (KTD-10) ──────────────────────────
|
|
// WIP limits are trait *config*; enforcement is a substrate capability
|
|
// that runs HERE, inside the move transaction, so two holds releasing into
|
|
// one slot serialize — exactly one commits, the other rejects and retries
|
|
// next sweep. It is NOT a guard: it runs regardless of bypassGuards /
|
|
// recoveryRehome / moveSource (engine/recovery/scheduler moves honor it
|
|
// too). Only a real column change into a capacity-bearing column is gated;
|
|
// same-column no-ops were returned earlier. The count is taken with the
|
|
// moving task EXCLUDED and the prospective slot it is about to occupy
|
|
// added back implicitly (it must fit alongside existing holders), so a
|
|
// full column (occupants == limit) rejects.
|
|
if (useWorkflow && workflowIr && fromColumn !== toColumn) {
|
|
const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove);
|
|
if (capacity.hasCapacity && Number.isFinite(capacity.limit)) {
|
|
const workflowId = this.resolveEffectiveWorkflowIdSync(id);
|
|
const occupants = this.countActiveInCapacitySlotSync({
|
|
targetColumn: toColumn,
|
|
workflowId,
|
|
countPending: capacity.countPending,
|
|
excludeTaskId: id,
|
|
});
|
|
if (occupants >= capacity.limit) {
|
|
throw new TransitionRejectionError(
|
|
makeTransitionRejection(
|
|
"capacity-exhausted",
|
|
"transition.rejected.capacityExhausted",
|
|
true,
|
|
`Column '${toColumn}' is at capacity (${occupants}/${capacity.limit})`,
|
|
),
|
|
`Cannot move ${id} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.upsertTaskWithFtsRecovery(task);
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
domain: "database",
|
|
mutationType: "task:move",
|
|
target: id,
|
|
metadata: {
|
|
from: fromColumn,
|
|
to: toColumn,
|
|
moveSource,
|
|
},
|
|
});
|
|
this.dequeueMergeQueueOnColumnExit(id, fromColumn, toColumn, movedAt);
|
|
|
|
// U4 (flag-ON): write the crash-safe transitionPending marker in the SAME
|
|
// transaction as the column change (KTD-2). It records the post-commit
|
|
// hooks that still owe idempotent execution so a crash mid-transition is
|
|
// recoverable from SQLite (the authoritative store, ADR-0001). The store
|
|
// clears it immediately after the post-commit hook runner completes
|
|
// (below). For the default workflow the field effects already applied
|
|
// in-lock; the marker guards the post-commit completion so recovery never
|
|
// double-runs (idempotent) and never strands the card.
|
|
if (useWorkflow) {
|
|
writeTransitionPending(
|
|
this.db,
|
|
id,
|
|
makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()),
|
|
);
|
|
}
|
|
|
|
if (toColumn === "in-review" && !internal.fromHandoff && options?.allowDirectInReviewMove !== true) {
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
domain: "database",
|
|
mutationType: "task:handoff-invariant-violation",
|
|
target: id,
|
|
metadata: {
|
|
taskId: id,
|
|
fromColumn,
|
|
callerStack: new Error().stack?.split("\n").slice(0, 8).join("\n"),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (internal.fromHandoff) {
|
|
alreadyEnqueued = Boolean(this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id));
|
|
this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now });
|
|
this.insertRunAuditEventRow({
|
|
taskId: id,
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
domain: "database",
|
|
mutationType: "task:handoff",
|
|
target: id,
|
|
metadata: {
|
|
taskId: id,
|
|
fromColumn,
|
|
ownerAgentId: internal.ownerAgentId ?? null,
|
|
reason: internal.evidence?.reason,
|
|
runId: internal.runContext?.runId,
|
|
agentId: internal.runContext?.agentId,
|
|
alreadyEnqueued,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
if (deletedAt) {
|
|
if (internal.fromHandoff) {
|
|
throw new HandoffInvariantViolationError(
|
|
id,
|
|
fromColumn,
|
|
`Cannot hand off ${id} to in-review because the task is deleted`,
|
|
);
|
|
}
|
|
this.throwSoftDeletedWriteBlocked(id, deletedAt, "moveTaskInternal", {
|
|
agentId: internal.runContext?.agentId,
|
|
runId: internal.runContext?.runId,
|
|
timestamp: movedAt,
|
|
});
|
|
}
|
|
|
|
await this.writeTaskJsonFile(dir, task);
|
|
if (fromColumn === "in-review" && toColumn === "todo" && moveSource === "user") {
|
|
const handoffAccepted = this.getCompletionHandoffAcceptedMarker(id);
|
|
const mergeRequest = this.getMergeRequestRecord(id);
|
|
if (handoffAccepted && mergeRequest && mergeRequest.state !== "succeeded" && mergeRequest.state !== "cancelled") {
|
|
if (mergeRequest.state === "queued" || mergeRequest.state === "running" || mergeRequest.state === "retrying" || mergeRequest.state === "manual-required") {
|
|
this.transitionMergeRequestState(id, "cancelled", {
|
|
attemptCount: mergeRequest.attemptCount,
|
|
lastError: mergeRequest.lastError ?? "cancelled-by-user-hard-cancel",
|
|
});
|
|
}
|
|
}
|
|
this.clearCompletionHandoffAcceptedMarker(id);
|
|
}
|
|
if (toColumn === "done") {
|
|
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
|
}
|
|
|
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
|
|
|
// U4 (flag-ON): post-commit hook completion. The default-workflow field
|
|
// effects already ran in-lock and committed; the post-commit phase here is
|
|
// the fire-and-forget hook runner per KTD-2. It is idempotent and clears the
|
|
// transitionPending marker once done. A crash before this point leaves the
|
|
// marker for the recovery sweep to re-run (re-running is a no-op for the
|
|
// default workflow's already-committed field effects).
|
|
//
|
|
// Residual C (U8): AFTER the built-in effects, invoke registered PLUGIN
|
|
// onExit (from column) / onEnter (to column) trait hook impls, recording
|
|
// per-hook completion in the marker's hooksRemaining. A throwing plugin hook
|
|
// DEGRADES (audit) and never wedges the lock or strands the marker — the
|
|
// marker is always cleared at the end regardless of hook failures.
|
|
if (useWorkflow) {
|
|
// Plugin hooks are skipped on engine/recovery-sourced moves (KTD-9 — those
|
|
// bypass trait effects) and on same-column no-ops.
|
|
if (!bypassGuards && fromColumn !== toColumn && workflowIr) {
|
|
try {
|
|
await this.runPluginColumnTransitionHooks(id, workflowIr, fromColumn, toColumn);
|
|
} catch (err) {
|
|
// The runner itself swallows per-hook failures; this is a final guard
|
|
// so a runner-level fault never strands the marker.
|
|
storeLog.warn("Plugin column transition hook runner faulted (degraded)", {
|
|
phase: "moveTaskInternal:plugin-hooks",
|
|
taskId: id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
clearTransitionPending(this.db, id);
|
|
} catch {
|
|
// Clearing is best-effort; the marker recovery sweep is the backstop.
|
|
}
|
|
}
|
|
|
|
if (fromColumn !== toColumn) {
|
|
this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource });
|
|
}
|
|
return task;
|
|
}
|
|
|
|
/**
|
|
* Residual C (U8): run registered PLUGIN onExit (from column) / onEnter (to
|
|
* column) trait hook impls AFTER the built-in default-workflow effects, on the
|
|
* post-commit path. Plugin hooks are async-only (KTD-7) and route through the
|
|
* registry's resolved impl (the engine wires `runCustomNode` in via the trait
|
|
* adapter; an unregistered/degraded hook resolves to a no-op + audit warning).
|
|
*
|
|
* Per-hook completion is recorded in the `transitionPending` marker's
|
|
* `hooksRemaining` so a crash mid-hook is recoverable. A hook that THROWS is
|
|
* audited (`plugin:trait-hook-degraded`) and treated as completed (removed
|
|
* from `hooksRemaining`) — a misbehaving plugin never wedges the task lock or
|
|
* strands the card (KTD-2 degraded-not-stranded posture). The caller clears
|
|
* the marker after this returns.
|
|
*/
|
|
private async runPluginColumnTransitionHooks(
|
|
taskId: string,
|
|
workflowIr: WorkflowIr,
|
|
fromColumn: string,
|
|
toColumn: string,
|
|
): Promise<void> {
|
|
const registry = getTraitRegistry();
|
|
// Collect (traitId, hookKind) pairs: onExit for from-column plugin traits,
|
|
// onEnter for to-column plugin traits. Only plugin-namespaced traits (KTD-7).
|
|
const pending: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = [];
|
|
const fromCol = findWorkflowColumn(workflowIr, fromColumn);
|
|
for (const ct of fromCol?.traits ?? []) {
|
|
if (!ct.trait.startsWith("plugin:")) continue;
|
|
const def = registry.getTrait(ct.trait);
|
|
if (def?.hooks?.onExit) pending.push({ traitId: ct.trait, hookKind: "onExit" });
|
|
}
|
|
const toCol = findWorkflowColumn(workflowIr, toColumn);
|
|
for (const ct of toCol?.traits ?? []) {
|
|
if (!ct.trait.startsWith("plugin:")) continue;
|
|
const def = registry.getTrait(ct.trait);
|
|
if (def?.hooks?.onEnter) pending.push({ traitId: ct.trait, hookKind: "onEnter" });
|
|
}
|
|
if (pending.length === 0) return;
|
|
|
|
// Record the plugin hooks in the marker's hooksRemaining (alongside the
|
|
// default-workflow:postCommit marker already written in-txn) so a crash
|
|
// mid-hook is recoverable.
|
|
const hookIds = pending.map((p) => `${p.traitId}:${p.hookKind}`);
|
|
const startedAt = Date.now();
|
|
try {
|
|
writeTransitionPending(
|
|
this.db,
|
|
taskId,
|
|
makeTransitionPending(toColumn, ["default-workflow:postCommit", ...hookIds], startedAt),
|
|
);
|
|
} catch {
|
|
// Marker bookkeeping is best-effort; proceed to run the hooks regardless.
|
|
}
|
|
|
|
// Read the task once for hook context. MUST be a non-locking read — this
|
|
// runs inside `withTaskLock`, so `getTask` (which re-acquires the lock)
|
|
// would deadlock. `readTaskFromDb` is the in-lock-safe read.
|
|
const taskRow = this.readTaskFromDb(taskId, { includeDeleted: false });
|
|
const taskDetail = taskRow as unknown as TaskDetail | undefined;
|
|
|
|
const remaining = ["default-workflow:postCommit", ...hookIds];
|
|
for (const { traitId, hookKind } of pending) {
|
|
const resolved = registry.resolveTraitHook(traitId, hookKind);
|
|
if (resolved.warning) {
|
|
// Degraded (no impl / force-disabled) → passive no-op, audit the warning.
|
|
this.recordRunAuditEvent({
|
|
taskId,
|
|
agentId: "system",
|
|
runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`,
|
|
domain: "database",
|
|
mutationType: "plugin:trait-hook-degraded",
|
|
target: taskId,
|
|
metadata: { traitId, hookKind, reason: "no-impl", message: resolved.warning.message },
|
|
});
|
|
} else if (resolved.impl) {
|
|
try {
|
|
await resolved.impl({ task: taskDetail, context: { fromColumn, toColumn, hookKind } });
|
|
} catch (err) {
|
|
// A throwing plugin hook DEGRADES — audited, never wedges the lock.
|
|
this.recordRunAuditEvent({
|
|
taskId,
|
|
agentId: "system",
|
|
runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`,
|
|
domain: "database",
|
|
mutationType: "plugin:trait-hook-degraded",
|
|
target: taskId,
|
|
metadata: {
|
|
traitId,
|
|
hookKind,
|
|
reason: "threw",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
// Mark this hook complete in the marker (whether it ran, degraded, or threw).
|
|
const idx = remaining.indexOf(`${traitId}:${hookKind}`);
|
|
if (idx >= 0) remaining.splice(idx, 1);
|
|
try {
|
|
writeTransitionPending(this.db, taskId, makeTransitionPending(toColumn, remaining, startedAt));
|
|
} catch {
|
|
// Best-effort progress bookkeeping; the final clear is the backstop.
|
|
}
|
|
}
|
|
}
|
|
|
|
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 updateTaskDependencies(
|
|
id: string,
|
|
mutation: TaskDependencyMutation,
|
|
runContext?: RunMutationContext,
|
|
): Promise<Task> {
|
|
return this.withTaskLock(id, async () => {
|
|
const dir = this.taskDir(id);
|
|
const task = await this.readTaskJson(dir);
|
|
const previousDependencies = [...(task.dependencies ?? [])];
|
|
const normalizedCurrent = previousDependencies.map((dependency) => dependency.trim()).filter(Boolean);
|
|
let nextDependencies: string[];
|
|
let action: string;
|
|
|
|
const assertNotSelf = (dependencyId: string) => {
|
|
if (dependencyId === id) {
|
|
throw new Error(`Task ${id} cannot depend on itself`);
|
|
}
|
|
};
|
|
const assertTaskExists = (dependencyId: string) => {
|
|
if (!this.readTaskFromDb(dependencyId)) {
|
|
throw new Error(`Dependency task ${dependencyId} not found`);
|
|
}
|
|
};
|
|
const assertUnique = (dependencies: readonly string[]) => {
|
|
const seen = new Set<string>();
|
|
for (const dependencyId of dependencies) {
|
|
if (seen.has(dependencyId)) {
|
|
throw new Error(`Task ${id} already depends on ${dependencyId}`);
|
|
}
|
|
seen.add(dependencyId);
|
|
}
|
|
};
|
|
const normalizeDependency = (dependencyId: string, label = "dependency") => {
|
|
const normalized = dependencyId.trim();
|
|
if (!normalized) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
assertNotSelf(normalized);
|
|
assertTaskExists(normalized);
|
|
return normalized;
|
|
};
|
|
|
|
switch (mutation.operation) {
|
|
case "add": {
|
|
const dependency = normalizeDependency(mutation.dependency);
|
|
if (normalizedCurrent.includes(dependency)) {
|
|
throw new Error(`Task ${id} already depends on ${dependency}`);
|
|
}
|
|
nextDependencies = [...normalizedCurrent, dependency];
|
|
action = `Added dependency ${dependency}`;
|
|
break;
|
|
}
|
|
case "remove": {
|
|
const dependency = mutation.dependency.trim();
|
|
if (!dependency) {
|
|
throw new Error("dependency is required");
|
|
}
|
|
if (!normalizedCurrent.includes(dependency)) {
|
|
throw new Error(`Task ${id} does not depend on ${dependency}`);
|
|
}
|
|
nextDependencies = normalizedCurrent.filter((candidate) => candidate !== dependency);
|
|
action = `Removed dependency ${dependency}`;
|
|
break;
|
|
}
|
|
case "replace": {
|
|
const from = mutation.from.trim();
|
|
if (!from) {
|
|
throw new Error("from dependency is required");
|
|
}
|
|
const to = normalizeDependency(mutation.to, "replacement dependency");
|
|
if (!normalizedCurrent.includes(from)) {
|
|
throw new Error(`Task ${id} does not depend on ${from}`);
|
|
}
|
|
if (from !== to && normalizedCurrent.includes(to)) {
|
|
throw new Error(`Task ${id} already depends on ${to}`);
|
|
}
|
|
nextDependencies = normalizedCurrent.map((dependency) => dependency === from ? to : dependency);
|
|
action = `Replaced dependency ${from} with ${to}`;
|
|
break;
|
|
}
|
|
case "set": {
|
|
nextDependencies = mutation.dependencies.map((dependency) => normalizeDependency(dependency));
|
|
assertUnique(nextDependencies);
|
|
action = nextDependencies.length > 0
|
|
? `Set dependencies to ${nextDependencies.join(", ")}`
|
|
: "Cleared dependencies";
|
|
break;
|
|
}
|
|
}
|
|
|
|
const selfDefeatingDep = detectSelfDefeatingDependency(task.title, nextDependencies);
|
|
if (selfDefeatingDep) {
|
|
throw new SelfDefeatingDependencyError(
|
|
task.title?.trim() ?? "",
|
|
selfDefeatingDep.matchedVerb,
|
|
selfDefeatingDep.operandTaskId,
|
|
);
|
|
}
|
|
|
|
await this.assertNoDependencyCycle(
|
|
id,
|
|
nextDependencies,
|
|
"updateTask",
|
|
new Map([[id, nextDependencies]]),
|
|
);
|
|
|
|
const previousDependencySet = new Set(normalizedCurrent);
|
|
const hasNewDependencies = nextDependencies.some((dependencyId) => !previousDependencySet.has(dependencyId));
|
|
|
|
task.dependencies = nextDependencies;
|
|
const unresolvedDependency = nextDependencies.find((dependencyId) => {
|
|
const dependency = this.readTaskFromDb(dependencyId);
|
|
return dependency?.column !== "done" && dependency?.column !== "archived";
|
|
});
|
|
if (unresolvedDependency) {
|
|
const currentBlocker = task.blockedBy ? this.readTaskFromDb(task.blockedBy) : undefined;
|
|
const currentBlockerResolved = currentBlocker?.column === "done" || currentBlocker?.column === "archived";
|
|
if (!task.blockedBy || !nextDependencies.includes(task.blockedBy) || !currentBlocker || currentBlockerResolved) {
|
|
task.blockedBy = unresolvedDependency;
|
|
}
|
|
} else {
|
|
task.blockedBy = undefined;
|
|
}
|
|
task.updatedAt = new Date().toISOString();
|
|
task.log ??= [];
|
|
let movedToTriage = false;
|
|
if (hasNewDependencies && task.column === "todo") {
|
|
task.column = "triage";
|
|
movedToTriage = true;
|
|
task.status = undefined;
|
|
task.columnMovedAt = task.updatedAt;
|
|
task.log.push({
|
|
timestamp: task.updatedAt,
|
|
action: "Moved to triage for re-specification — new dependency added",
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
}
|
|
task.log.push({
|
|
timestamp: task.updatedAt,
|
|
action,
|
|
...(runContext ? { runContext } : {}),
|
|
});
|
|
|
|
const auditEvent: RunAuditEventInput = {
|
|
taskId: id,
|
|
agentId: runContext?.agentId ?? "manual",
|
|
runId: runContext?.runId ?? "manual",
|
|
domain: "database",
|
|
mutationType: "task:dependencies:update",
|
|
target: id,
|
|
metadata: {
|
|
mutation,
|
|
previousDependencies,
|
|
dependencies: nextDependencies,
|
|
blockedBy: task.blockedBy ?? null,
|
|
},
|
|
};
|
|
await this.atomicWriteTaskJsonWithAudit(dir, task, auditEvent);
|
|
if (movedToTriage) {
|
|
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
|
|
}
|
|
this.emitTaskLifecycleEventSafely("task:updated", [task]);
|
|
return task;
|
|
});
|
|
}
|
|
|
|
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; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | 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; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; 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, () => this.updateTaskUnlocked(id, updates, runContext));
|
|
}
|
|
|
|
/**
|
|
* The body of {@link updateTask} WITHOUT acquiring the per-task lock. Callers
|
|
* that already hold `withTaskLock(id)` — e.g. workflow-selection mutations
|
|
* that bundle a `task_workflow_selection`/`workflow_steps` write with the
|
|
* `enabledWorkflowSteps` update — invoke this directly so the whole sequence
|
|
* runs under a single lock acquisition. The per-task lock is non-reentrant,
|
|
* so calling the public `updateTask` from inside an outer `withTaskLock(id)`
|
|
* would deadlock; this variant exists to avoid that.
|
|
*/
|
|
private async updateTaskUnlocked(
|
|
id: string,
|
|
updates: Parameters<TaskStore["updateTask"]>[1],
|
|
runContext?: RunMutationContext,
|
|
): Promise<Task> {
|
|
{
|
|
if (updates.dependencies !== undefined) {
|
|
await this.assertNoDependencyCycle(
|
|
id,
|
|
updates.dependencies,
|
|
"updateTask",
|
|
new Map([[id, updates.dependencies]]),
|
|
);
|
|
}
|
|
|
|
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 ?? []).map((dependency) => dependency.trim()).filter(Boolean));
|
|
const normalizedDependencies = updates.dependencies.map((dependency) => dependency.trim()).filter(Boolean);
|
|
const hasNewDeps = normalizedDependencies.some((d) => !oldDeps.has(d));
|
|
task.dependencies = normalizedDependencies;
|
|
|
|
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.scopeAutoWiden === null) {
|
|
task.scopeAutoWiden = undefined;
|
|
} else if (updates.scopeAutoWiden !== undefined) {
|
|
task.scopeAutoWiden = [...updates.scopeAutoWiden];
|
|
}
|
|
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.autoMerge === null) {
|
|
task.autoMerge = undefined;
|
|
} else if (updates.autoMerge !== undefined) {
|
|
task.autoMerge = updates.autoMerge;
|
|
}
|
|
if (updates.branch === null) {
|
|
task.branch = undefined;
|
|
} else if (updates.branch !== undefined) {
|
|
task.branch = updates.branch;
|
|
}
|
|
if (updates.autoMerge === null) {
|
|
task.autoMerge = undefined;
|
|
} else if (updates.autoMerge !== undefined) {
|
|
task.autoMerge = updates.autoMerge;
|
|
}
|
|
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.resumeLimboCount === null) {
|
|
task.resumeLimboCount = undefined;
|
|
} else if (updates.resumeLimboCount !== undefined) {
|
|
task.resumeLimboCount = updates.resumeLimboCount;
|
|
}
|
|
if (updates.resumeLimboTipSha === null) {
|
|
task.resumeLimboTipSha = undefined;
|
|
} else if (updates.resumeLimboTipSha !== undefined) {
|
|
task.resumeLimboTipSha = updates.resumeLimboTipSha;
|
|
}
|
|
if (updates.resumeLimboStepSignature === null) {
|
|
task.resumeLimboStepSignature = undefined;
|
|
} else if (updates.resumeLimboStepSignature !== undefined) {
|
|
task.resumeLimboStepSignature = updates.resumeLimboStepSignature;
|
|
}
|
|
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.mergeTransientRetryCount === null) {
|
|
task.mergeTransientRetryCount = undefined;
|
|
} else if (updates.mergeTransientRetryCount !== undefined) {
|
|
task.mergeTransientRetryCount = updates.mergeTransientRetryCount;
|
|
}
|
|
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.emitTaskLifecycleEventSafely("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.emitTaskLifecycleEventSafely("task:updated", [current]);
|
|
return current;
|
|
}
|
|
|
|
const emittedTask = ({ id, log, updatedAt } as unknown) as Task;
|
|
this.emitTaskLifecycleEventSafely("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 ───────────────────────────────────────────────────
|
|
|
|
private rowToMergeQueueEntry(row: MergeQueueRow): MergeQueueEntry {
|
|
return {
|
|
taskId: row.taskId,
|
|
enqueuedAt: row.enqueuedAt,
|
|
priority: normalizeTaskPriority(row.priority),
|
|
leasedBy: row.leasedBy,
|
|
leasedAt: row.leasedAt,
|
|
leaseExpiresAt: row.leaseExpiresAt,
|
|
attemptCount: row.attemptCount,
|
|
lastError: row.lastError,
|
|
};
|
|
}
|
|
|
|
private normalizeMergeRequestState(value: string): MergeRequestState {
|
|
switch (value) {
|
|
case "queued":
|
|
case "running":
|
|
case "retrying":
|
|
case "succeeded":
|
|
case "exhausted":
|
|
case "cancelled":
|
|
case "manual-required":
|
|
return value;
|
|
default:
|
|
return "queued";
|
|
}
|
|
}
|
|
|
|
private rowToMergeRequestRecord(row: MergeRequestRow): MergeRequestRecord {
|
|
return {
|
|
taskId: row.taskId,
|
|
state: this.normalizeMergeRequestState(row.state),
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
attemptCount: row.attemptCount,
|
|
lastError: row.lastError,
|
|
};
|
|
}
|
|
|
|
private rowToCompletionHandoffMarker(row: CompletionHandoffMarkerRow): CompletionHandoffMarker {
|
|
return {
|
|
taskId: row.taskId,
|
|
acceptedAt: row.acceptedAt,
|
|
source: row.source,
|
|
};
|
|
}
|
|
|
|
private isValidMergeRequestTransition(from: MergeRequestState, to: MergeRequestState): boolean {
|
|
if (from === to) return true;
|
|
const allowed: Record<MergeRequestState, ReadonlySet<MergeRequestState>> = {
|
|
queued: new Set(["running", "cancelled"]),
|
|
running: new Set(["retrying", "succeeded", "exhausted", "cancelled"]),
|
|
retrying: new Set(["queued", "cancelled", "exhausted"]),
|
|
succeeded: new Set([]),
|
|
exhausted: new Set([]),
|
|
cancelled: new Set([]),
|
|
"manual-required": new Set(["succeeded", "cancelled"]),
|
|
};
|
|
return allowed[from].has(to);
|
|
}
|
|
|
|
upsertMergeRequestRecord(
|
|
taskId: string,
|
|
input: { state: MergeRequestState; now?: string; attemptCount?: number; lastError?: string | null },
|
|
): MergeRequestRecord {
|
|
return this.db.transactionImmediate(() => {
|
|
const now = input.now ?? new Date().toISOString();
|
|
this.db.prepare(`
|
|
INSERT INTO merge_requests (taskId, state, createdAt, updatedAt, attemptCount, lastError)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(taskId) DO UPDATE SET
|
|
state = excluded.state,
|
|
updatedAt = excluded.updatedAt,
|
|
attemptCount = excluded.attemptCount,
|
|
lastError = excluded.lastError
|
|
`).run(taskId, input.state, now, now, input.attemptCount ?? 0, input.lastError ?? null);
|
|
|
|
const row = this.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined;
|
|
if (!row) throw new Error(`Failed to upsert merge request for ${taskId}`);
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeRequest:upsert",
|
|
target: taskId,
|
|
metadata: { taskId, state: row.state, attemptCount: row.attemptCount, lastError: row.lastError },
|
|
});
|
|
|
|
return this.rowToMergeRequestRecord(row);
|
|
});
|
|
}
|
|
|
|
transitionMergeRequestState(
|
|
taskId: string,
|
|
toState: MergeRequestState,
|
|
opts: { now?: string; attemptCount?: number; lastError?: string | null } = {},
|
|
): MergeRequestRecord {
|
|
return this.db.transactionImmediate(() => {
|
|
const now = opts.now ?? new Date().toISOString();
|
|
const existing = this.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined;
|
|
if (!existing) {
|
|
throw new Error(`Merge request record not found for ${taskId}`);
|
|
}
|
|
const fromState = this.normalizeMergeRequestState(existing.state);
|
|
if (!this.isValidMergeRequestTransition(fromState, toState)) {
|
|
throw new Error(`Invalid merge request state transition for ${taskId}: ${fromState} -> ${toState}`);
|
|
}
|
|
|
|
this.db.prepare(`
|
|
UPDATE merge_requests
|
|
SET state = ?,
|
|
updatedAt = ?,
|
|
attemptCount = ?,
|
|
lastError = ?
|
|
WHERE taskId = ?
|
|
`).run(toState, now, opts.attemptCount ?? existing.attemptCount, opts.lastError ?? existing.lastError, taskId);
|
|
|
|
const updated = this.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined;
|
|
if (!updated) throw new Error(`Merge request record disappeared for ${taskId}`);
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeRequest:transition",
|
|
target: taskId,
|
|
metadata: { taskId, fromState, toState, attemptCount: updated.attemptCount, lastError: updated.lastError },
|
|
});
|
|
return this.rowToMergeRequestRecord(updated);
|
|
});
|
|
}
|
|
|
|
getMergeRequestRecord(taskId: string): MergeRequestRecord | null {
|
|
const row = this.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined;
|
|
return row ? this.rowToMergeRequestRecord(row) : null;
|
|
}
|
|
|
|
setCompletionHandoffAcceptedMarker(
|
|
taskId: string,
|
|
opts: { source: string; acceptedAt?: string },
|
|
): CompletionHandoffMarker {
|
|
return this.db.transactionImmediate(() => {
|
|
const acceptedAt = opts.acceptedAt ?? new Date().toISOString();
|
|
this.db.prepare(`
|
|
INSERT INTO completion_handoff_markers (taskId, acceptedAt, source)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(taskId) DO UPDATE SET
|
|
acceptedAt = excluded.acceptedAt,
|
|
source = excluded.source
|
|
`).run(taskId, acceptedAt, opts.source);
|
|
|
|
const row = this.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined;
|
|
if (!row) throw new Error(`Failed to set completion handoff marker for ${taskId}`);
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "task:completion-handoff-accepted",
|
|
target: taskId,
|
|
metadata: { taskId, acceptedAt: row.acceptedAt, source: row.source },
|
|
});
|
|
|
|
return this.rowToCompletionHandoffMarker(row);
|
|
});
|
|
}
|
|
|
|
clearCompletionHandoffAcceptedMarker(taskId: string): void {
|
|
this.db.transactionImmediate(() => {
|
|
const existing = this.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined;
|
|
if (!existing) return;
|
|
this.db.prepare("DELETE FROM completion_handoff_markers WHERE taskId = ?").run(taskId);
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "task:completion-handoff-cleared",
|
|
target: taskId,
|
|
metadata: { taskId, acceptedAt: existing.acceptedAt, source: existing.source },
|
|
});
|
|
});
|
|
}
|
|
|
|
getCompletionHandoffAcceptedMarker(taskId: string): CompletionHandoffMarker | null {
|
|
const row = this.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined;
|
|
return row ? this.rowToCompletionHandoffMarker(row) : null;
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Aggregate the dual-observe parity audit events (CU-U5) into the graduation
|
|
* signal: how often the interpreter's shadow observation agreed with the
|
|
* legacy authoritative run, and which fields drift when it doesn't.
|
|
*/
|
|
getWorkflowParitySummary(options: { since?: string; limit?: number } = {}): WorkflowParitySummary {
|
|
const limit = options.limit ?? 1000;
|
|
const observed = this.getRunAuditEvents({
|
|
domain: "database",
|
|
mutationType: WORKFLOW_PARITY_OBSERVED_MUTATION as unknown as RunAuditEvent["mutationType"],
|
|
startTime: options.since,
|
|
limit,
|
|
});
|
|
const driftEvents = this.getRunAuditEvents({
|
|
domain: "database",
|
|
mutationType: WORKFLOW_PARITY_DRIFT_MUTATION as unknown as RunAuditEvent["mutationType"],
|
|
startTime: options.since,
|
|
limit,
|
|
});
|
|
|
|
let agreed = 0;
|
|
for (const event of observed) {
|
|
if (event.metadata?.agree === true) agreed += 1;
|
|
}
|
|
|
|
const driftFieldCounts: Record<string, number> = {};
|
|
const recentDrift: WorkflowParitySummary["recentDrift"] = [];
|
|
for (const event of driftEvents) {
|
|
const diffs = Array.isArray(event.metadata?.diffs)
|
|
? (event.metadata.diffs as WorkflowParityDiff[])
|
|
: [];
|
|
for (const diff of diffs) {
|
|
driftFieldCounts[diff.field] = (driftFieldCounts[diff.field] ?? 0) + 1;
|
|
}
|
|
if (recentDrift.length < 20) {
|
|
recentDrift.push({ taskId: event.taskId ?? event.target, timestamp: event.timestamp, diffs });
|
|
}
|
|
}
|
|
|
|
return {
|
|
observed: observed.length,
|
|
agreed,
|
|
drift: driftEvents.length,
|
|
agreeRate: observed.length > 0 ? agreed / observed.length : 0,
|
|
driftFieldCounts,
|
|
recentDrift,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aggregate the `workflowColumns` flag default-flip criteria (U12, KTD-8) into
|
|
* a single graduation report: five-invariant dual-observe parity, the default
|
|
* workflow's transition parity vs VALID_TRANSITIONS, and the dual-accept
|
|
* marker/column disagreement count (U6, FN-5719). The flip is a FIELD decision
|
|
* — this report is the GATE. Does NOT flip the flag; callers inspect `ready`
|
|
* and `blockers`.
|
|
*/
|
|
computeWorkflowColumnsGraduationReport(
|
|
options: { since?: string; limit?: number } = {},
|
|
): WorkflowColumnsGraduationReport {
|
|
const limit = options.limit ?? 1000;
|
|
const parity = this.getWorkflowParitySummary(options);
|
|
const dualAcceptEvents: RunAuditEvent[] = [];
|
|
for (const mutationType of DUAL_ACCEPT_PARITY_MUTATIONS) {
|
|
dualAcceptEvents.push(
|
|
...this.getRunAuditEvents({
|
|
domain: "database",
|
|
mutationType: mutationType as unknown as RunAuditEvent["mutationType"],
|
|
startTime: options.since,
|
|
limit,
|
|
}),
|
|
);
|
|
}
|
|
return computeWorkflowColumnsGraduationReport({
|
|
parity,
|
|
defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR,
|
|
dualAcceptEvents,
|
|
});
|
|
}
|
|
|
|
enqueueMergeQueue(taskId: string, opts: MergeQueueEnqueueOptions = {}): MergeQueueEntry {
|
|
let invalidColumn: Column | null = null;
|
|
const entry = this.db.transactionImmediate(() => {
|
|
const existing = this.db.prepare("SELECT * FROM mergeQueue WHERE taskId = ?").get(taskId) as MergeQueueRow | undefined;
|
|
const taskRow = this.db.prepare("SELECT priority, column FROM tasks WHERE id = ?").get(taskId) as { priority: string | null; column: Column } | undefined;
|
|
if (!taskRow) {
|
|
throw new MergeQueueTaskNotFoundError(taskId);
|
|
}
|
|
if (taskRow.column !== "in-review") {
|
|
invalidColumn = taskRow.column;
|
|
return null;
|
|
}
|
|
|
|
const now = opts.now ?? new Date().toISOString();
|
|
const priority = opts.priority ?? normalizeTaskPriority(taskRow.priority);
|
|
|
|
let nextEntry: MergeQueueEntry;
|
|
let alreadyEnqueued = true;
|
|
if (existing) {
|
|
nextEntry = this.rowToMergeQueueEntry(existing);
|
|
} else {
|
|
this.db.prepare(`
|
|
INSERT INTO mergeQueue (taskId, enqueuedAt, priority, attemptCount)
|
|
VALUES (?, ?, ?, 0)
|
|
ON CONFLICT(taskId) DO NOTHING
|
|
`).run(taskId, now, priority);
|
|
const inserted = this.db.prepare("SELECT * FROM mergeQueue WHERE taskId = ?").get(taskId) as MergeQueueRow | undefined;
|
|
if (!inserted) {
|
|
throw new Error(`Failed to read merge queue entry for ${taskId} after enqueue`);
|
|
}
|
|
nextEntry = this.rowToMergeQueueEntry(inserted);
|
|
alreadyEnqueued = false;
|
|
}
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:enqueue",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
priority: nextEntry.priority,
|
|
enqueuedAt: nextEntry.enqueuedAt,
|
|
alreadyEnqueued,
|
|
},
|
|
});
|
|
|
|
return nextEntry;
|
|
});
|
|
|
|
if (invalidColumn) {
|
|
this.db.transactionImmediate(() => {
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:enqueue-rejected",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
column: invalidColumn,
|
|
reason: "not-in-review",
|
|
},
|
|
});
|
|
});
|
|
throw new MergeQueueInvalidColumnError(taskId, invalidColumn);
|
|
}
|
|
|
|
if (!entry) {
|
|
throw new Error(`Failed to enqueue merge queue entry for ${taskId}`);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
private cleanupStaleMergeQueueRows(now: string): void {
|
|
const staleRows = this.db.prepare(`
|
|
SELECT mq.taskId, mq.leasedBy, mq.leaseExpiresAt, t.column
|
|
FROM mergeQueue mq
|
|
LEFT JOIN tasks t ON t.id = mq.taskId
|
|
WHERE t.id IS NULL OR t.column != 'in-review'
|
|
`).all() as Array<{ taskId: string; leasedBy: string | null; leaseExpiresAt: string | null; column: Column | null }>;
|
|
|
|
for (const staleRow of staleRows) {
|
|
this.db.prepare("DELETE FROM mergeQueue WHERE taskId = ?").run(staleRow.taskId);
|
|
this.insertRunAuditEventRow({
|
|
taskId: staleRow.taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:auto-cleanup-stale-row",
|
|
target: staleRow.taskId,
|
|
metadata: {
|
|
taskId: staleRow.taskId,
|
|
column: staleRow.column,
|
|
leasedBy: staleRow.leasedBy,
|
|
leaseExpiresAt: staleRow.leaseExpiresAt,
|
|
cleanedAt: now,
|
|
reason: "not-in-review",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: Column, nextColumn: Column, now: string): void {
|
|
if (previousColumn !== "in-review" || nextColumn === "in-review") {
|
|
return;
|
|
}
|
|
|
|
const queueRow = this.db.prepare("SELECT leasedBy, leaseExpiresAt FROM mergeQueue WHERE taskId = ?").get(taskId) as {
|
|
leasedBy: string | null;
|
|
leaseExpiresAt: string | null;
|
|
} | undefined;
|
|
if (!queueRow) {
|
|
return;
|
|
}
|
|
|
|
const leaseIsExpired = queueRow.leaseExpiresAt != null && queueRow.leaseExpiresAt <= now;
|
|
if (!queueRow.leasedBy || leaseIsExpired) {
|
|
this.db.prepare("DELETE FROM mergeQueue WHERE taskId = ?").run(taskId);
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:auto-cleanup-stale-row",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
previousColumn,
|
|
nextColumn,
|
|
leasedBy: queueRow.leasedBy,
|
|
leaseExpiresAt: queueRow.leaseExpiresAt,
|
|
cleanedAt: now,
|
|
reason: "column-exit",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:stale-lease-on-column-exit",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
previousColumn,
|
|
nextColumn,
|
|
leasedBy: queueRow.leasedBy,
|
|
leaseExpiresAt: queueRow.leaseExpiresAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
acquireMergeQueueLease(workerId: string, opts: MergeQueueAcquireOptions): MergeQueueEntry | null {
|
|
if (opts.leaseDurationMs <= 0) {
|
|
throw new InvalidMergeQueueLeaseDurationError(opts.leaseDurationMs);
|
|
}
|
|
|
|
return this.db.transactionImmediate(() => {
|
|
const now = opts.now ?? new Date().toISOString();
|
|
const leaseExpiresAt = new Date(Date.parse(now) + opts.leaseDurationMs).toISOString();
|
|
this.cleanupStaleMergeQueueRows(now);
|
|
|
|
let leased: MergeQueueRow | undefined;
|
|
if (opts.targetTaskId) {
|
|
leased = this.db.prepare(`
|
|
UPDATE mergeQueue
|
|
SET leasedBy = ?, leasedAt = ?, leaseExpiresAt = ?
|
|
WHERE taskId = ?
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM tasks t
|
|
WHERE t.id = mergeQueue.taskId
|
|
AND t.column = 'in-review'
|
|
)
|
|
AND (leasedBy IS NULL OR leaseExpiresAt <= ?)
|
|
RETURNING *
|
|
`).get(workerId, now, leaseExpiresAt, opts.targetTaskId, now) as MergeQueueRow | undefined;
|
|
|
|
if (!leased) {
|
|
const queueHead = this.db.prepare(`
|
|
SELECT mq.taskId, mq.leasedBy, t.column
|
|
FROM mergeQueue mq
|
|
LEFT JOIN tasks t ON t.id = mq.taskId
|
|
ORDER BY CASE mq.priority
|
|
WHEN 'urgent' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'low' THEN 3
|
|
ELSE 4
|
|
END ASC,
|
|
mq.enqueuedAt ASC
|
|
LIMIT 1
|
|
`).get() as { taskId: string; leasedBy: string | null; column: string | null } | undefined;
|
|
|
|
this.insertRunAuditEventRow({
|
|
taskId: opts.targetTaskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:lease-target-unavailable",
|
|
target: opts.targetTaskId,
|
|
metadata: {
|
|
targetTaskId: opts.targetTaskId,
|
|
workerId,
|
|
queueHeadTaskId: queueHead?.taskId ?? null,
|
|
queueHeadLeasedBy: queueHead?.leasedBy ?? null,
|
|
queueHeadColumn: queueHead?.column ?? null,
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
} else {
|
|
leased = this.db.prepare(`
|
|
UPDATE mergeQueue
|
|
SET leasedBy = ?, leasedAt = ?, leaseExpiresAt = ?
|
|
WHERE taskId = (
|
|
SELECT mq.taskId
|
|
FROM mergeQueue mq
|
|
JOIN tasks t ON t.id = mq.taskId
|
|
WHERE t.column = 'in-review'
|
|
AND (mq.leasedBy IS NULL OR mq.leaseExpiresAt <= ?)
|
|
ORDER BY CASE mq.priority
|
|
WHEN 'urgent' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'low' THEN 3
|
|
ELSE 4
|
|
END ASC,
|
|
mq.enqueuedAt ASC
|
|
LIMIT 1
|
|
)
|
|
RETURNING *
|
|
`).get(workerId, now, leaseExpiresAt, now) as MergeQueueRow | undefined;
|
|
|
|
if (!leased) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const entry = this.rowToMergeQueueEntry(leased);
|
|
this.insertRunAuditEventRow({
|
|
taskId: entry.taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:lease-acquired",
|
|
target: entry.taskId,
|
|
metadata: {
|
|
taskId: entry.taskId,
|
|
workerId,
|
|
leaseExpiresAt: entry.leaseExpiresAt,
|
|
priority: entry.priority,
|
|
},
|
|
});
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
releaseMergeQueueLease(taskId: string, workerId: string, outcome: MergeQueueReleaseOutcome): void {
|
|
this.db.transactionImmediate(() => {
|
|
const current = this.db.prepare("SELECT leasedBy FROM mergeQueue WHERE taskId = ?").get(taskId) as { leasedBy: string | null } | undefined;
|
|
if (!current || current.leasedBy !== workerId) {
|
|
throw new MergeQueueLeaseOwnershipError(taskId, workerId, current?.leasedBy ?? null);
|
|
}
|
|
|
|
if (outcome.kind === "success") {
|
|
this.db.prepare("DELETE FROM mergeQueue WHERE taskId = ? AND leasedBy = ?").run(taskId, workerId);
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:lease-released",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
workerId,
|
|
outcome: "success",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const released = this.db.prepare(`
|
|
UPDATE mergeQueue
|
|
SET leasedBy = NULL,
|
|
leasedAt = NULL,
|
|
leaseExpiresAt = NULL,
|
|
attemptCount = attemptCount + 1,
|
|
lastError = ?
|
|
WHERE taskId = ? AND leasedBy = ?
|
|
RETURNING *
|
|
`).get(outcome.error, taskId, workerId) as MergeQueueRow | undefined;
|
|
if (!released) {
|
|
throw new MergeQueueLeaseOwnershipError(taskId, workerId, null);
|
|
}
|
|
|
|
const entry = this.rowToMergeQueueEntry(released);
|
|
this.insertRunAuditEventRow({
|
|
taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:lease-released",
|
|
target: taskId,
|
|
metadata: {
|
|
taskId,
|
|
workerId,
|
|
outcome: "failure",
|
|
attemptCount: entry.attemptCount,
|
|
error: outcome.error,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
recoverExpiredMergeQueueLeases(now: string = new Date().toISOString()): MergeQueueEntry[] {
|
|
return this.db.transactionImmediate(() => {
|
|
const expired = this.db.prepare(`
|
|
SELECT * FROM mergeQueue
|
|
WHERE leasedBy IS NOT NULL AND leaseExpiresAt <= ?
|
|
ORDER BY leaseExpiresAt ASC, enqueuedAt ASC
|
|
`).all(now) as MergeQueueRow[];
|
|
if (expired.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const recoveredRows = this.db.prepare(`
|
|
UPDATE mergeQueue
|
|
SET leasedBy = NULL,
|
|
leasedAt = NULL,
|
|
leaseExpiresAt = NULL
|
|
WHERE leasedBy IS NOT NULL AND leaseExpiresAt <= ?
|
|
RETURNING *
|
|
`).all(now) as MergeQueueRow[];
|
|
|
|
const previousByTaskId = new Map(expired.map((row) => [row.taskId, row]));
|
|
for (const row of recoveredRows) {
|
|
const previous = previousByTaskId.get(row.taskId);
|
|
this.insertRunAuditEventRow({
|
|
taskId: row.taskId,
|
|
domain: "database",
|
|
mutationType: "mergeQueue:lease-expired",
|
|
target: row.taskId,
|
|
metadata: {
|
|
taskId: row.taskId,
|
|
previousLeasedBy: previous?.leasedBy ?? null,
|
|
previousLeaseExpiresAt: previous?.leaseExpiresAt ?? null,
|
|
recoveredAt: now,
|
|
},
|
|
});
|
|
}
|
|
|
|
return recoveredRows.map((row) => this.rowToMergeQueueEntry(row));
|
|
});
|
|
}
|
|
|
|
peekMergeQueue(): MergeQueueEntry[] {
|
|
const rows = this.db.prepare(`
|
|
SELECT * FROM mergeQueue
|
|
ORDER BY CASE priority
|
|
WHEN 'urgent' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'low' THEN 3
|
|
ELSE 4
|
|
END ASC,
|
|
enqueuedAt ASC
|
|
`).all() as MergeQueueRow[];
|
|
return rows.map((row) => this.rowToMergeQueueEntry(row));
|
|
}
|
|
|
|
peekMergeQueueHead(): { taskId: string; leasedBy: string | null; column: Column | null } | null {
|
|
const row = this.db.prepare(`
|
|
SELECT mq.taskId, mq.leasedBy, t.column
|
|
FROM mergeQueue mq
|
|
LEFT JOIN tasks t ON t.id = mq.taskId
|
|
ORDER BY CASE mq.priority
|
|
WHEN 'urgent' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'low' THEN 3
|
|
ELSE 4
|
|
END ASC,
|
|
mq.enqueuedAt ASC
|
|
LIMIT 1
|
|
`).get() as { taskId: string; leasedBy: string | null; column: Column | null } | undefined;
|
|
return row ?? null;
|
|
}
|
|
|
|
// ── 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));
|
|
}
|
|
|
|
private makeSyntheticDeleteRunId(taskId: string): string {
|
|
return `synthetic-task-delete-${taskId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
removeLineageReferences?: boolean;
|
|
allowResurrection?: boolean;
|
|
githubIssueAction?: GithubIssueAction;
|
|
auditContext?: { agentId: string; runId: string; sessionId?: string };
|
|
},
|
|
): 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);
|
|
}
|
|
|
|
// FN-5127: lineage gate must execute after idempotent short-circuit.
|
|
const lineageChildIds = this.findLiveLineageChildren(id);
|
|
if (lineageChildIds.length > 0 && !options?.removeLineageReferences) {
|
|
throw new TaskHasLineageChildrenError(id, lineageChildIds);
|
|
}
|
|
|
|
// 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[] = [];
|
|
let rewrittenBlockedByResidueDependents: Task[] = [];
|
|
let rewrittenLineageChildren: Task[] = [];
|
|
this.db.transaction(() => {
|
|
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
|
rewrittenBlockedByResidueDependents = this.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds));
|
|
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
|
const deletedAt = new Date().toISOString();
|
|
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
|
|
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id);
|
|
this.recordRunAuditEvent({
|
|
domain: "database",
|
|
mutationType: "task:deleted",
|
|
target: task.id,
|
|
taskId: task.id,
|
|
agentId: options?.auditContext?.agentId ?? "system",
|
|
runId: options?.auditContext?.runId ?? this.makeSyntheticDeleteRunId(task.id),
|
|
metadata: {
|
|
previousColumn: task.column,
|
|
previousStatus: task.status ?? null,
|
|
githubIssueAction: options?.githubIssueAction ?? "auto",
|
|
removeDependencyReferences: !!options?.removeDependencyReferences,
|
|
removeLineageReferences: !!options?.removeLineageReferences,
|
|
allowResurrection: options?.allowResurrection === true,
|
|
sessionId: options?.auditContext?.sessionId,
|
|
},
|
|
});
|
|
this.clearLinkedAgentTaskIds(id, deletedAt);
|
|
// FN-5143: agent log reads are gated on deletedAt (see getAgentLogs /
|
|
// getAgentLogCount / getAgentLogsByTimeRange), so downstream readers
|
|
// observe zero logs immediately after deletedAt is set. The JSONL file
|
|
// remains on disk for forensic analysis; only the read API hides it.
|
|
this.db.bumpLastModified();
|
|
});
|
|
|
|
// FN-5143 defense-in-depth: drop any in-memory buffer entries for this
|
|
// task. flushAgentLogBuffer() above already ran inside the lock, but a
|
|
// concurrent appendAgentLog from another async path could re-buffer
|
|
// before this lock releases; the next flush would still drop them via
|
|
// ACTIVE_TASKS_WHERE, but filtering here avoids the warn log and keeps
|
|
// memory bounded.
|
|
if (this.agentLogBuffer.length > 0) {
|
|
this.agentLogBuffer = this.agentLogBuffer.filter((entry) => entry.taskId !== id);
|
|
}
|
|
|
|
// Remove from cache if watcher is active
|
|
if (this.isWatching) this.taskCache.delete(id);
|
|
|
|
for (const dependentTask of rewrittenDependents) {
|
|
this.emit("task:updated", dependentTask);
|
|
}
|
|
for (const dependentTask of rewrittenBlockedByResidueDependents) {
|
|
this.emit("task:updated", dependentTask);
|
|
}
|
|
for (const lineageChild of rewrittenLineageChildren) {
|
|
this.emit("task:updated", lineageChild);
|
|
}
|
|
|
|
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.purgeTaskWorkflowSelectionRows(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);
|
|
const clearsBlockedBy = dependentTask.blockedBy === taskId;
|
|
if (nextDependencies.length === dependentTask.dependencies.length && !clearsBlockedBy) {
|
|
continue;
|
|
}
|
|
|
|
const updatedLog = clearsBlockedBy
|
|
? [
|
|
...(dependentTask.log ?? []),
|
|
{
|
|
timestamp: new Date().toISOString(),
|
|
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
|
|
},
|
|
]
|
|
: dependentTask.log;
|
|
const updatedDependent: Task = {
|
|
...dependentTask,
|
|
dependencies: nextDependencies,
|
|
blockedBy: clearsBlockedBy ? undefined : dependentTask.blockedBy,
|
|
status: clearsBlockedBy ? undefined : dependentTask.status,
|
|
log: updatedLog,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
this.db.prepare("UPDATE tasks SET dependencies = ?, blockedBy = ?, status = ?, log = ?, updatedAt = ? WHERE id = ?").run(
|
|
toJson(updatedDependent.dependencies),
|
|
updatedDependent.blockedBy ?? null,
|
|
updatedDependent.status ?? null,
|
|
toJson(updatedDependent.log ?? []),
|
|
updatedDependent.updatedAt,
|
|
updatedDependent.id,
|
|
);
|
|
if (this.isWatching) {
|
|
this.taskCache.set(updatedDependent.id, updatedDependent);
|
|
}
|
|
rewrittenDependents.push(updatedDependent);
|
|
}
|
|
|
|
return rewrittenDependents;
|
|
}
|
|
|
|
private rewriteBlockedByResidueDependentsForRemoval(taskId: string, excludedDependentIds: Set<string>): Task[] {
|
|
const rewrittenDependents: Task[] = [];
|
|
const candidates = this.db
|
|
.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND blockedBy = ?`)
|
|
.all(taskId) as Array<{ id: string }>;
|
|
|
|
for (const candidate of candidates) {
|
|
if (excludedDependentIds.has(candidate.id)) continue;
|
|
const dependentTask = this.readTaskFromDb(candidate.id);
|
|
if (!dependentTask || dependentTask.blockedBy !== taskId) continue;
|
|
|
|
const updatedDependent: Task = {
|
|
...dependentTask,
|
|
blockedBy: undefined,
|
|
status: undefined,
|
|
log: [
|
|
...(dependentTask.log ?? []),
|
|
{
|
|
timestamp: new Date().toISOString(),
|
|
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
|
|
},
|
|
],
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
this.db.prepare("UPDATE tasks SET blockedBy = NULL, status = NULL, log = ?, updatedAt = ? WHERE id = ?").run(
|
|
toJson(updatedDependent.log ?? []),
|
|
updatedDependent.updatedAt,
|
|
updatedDependent.id,
|
|
);
|
|
|
|
if (this.isWatching) {
|
|
this.taskCache.set(updatedDependent.id, updatedDependent);
|
|
}
|
|
rewrittenDependents.push(updatedDependent);
|
|
}
|
|
|
|
return rewrittenDependents;
|
|
}
|
|
|
|
private rewriteLineageChildrenForRemoval(parentId: string, childIds: string[]): Task[] {
|
|
const rewrittenChildren: Task[] = [];
|
|
|
|
for (const childId of childIds) {
|
|
const childTask = this.readTaskFromDb(childId);
|
|
if (!childTask || childTask.sourceParentTaskId !== parentId) continue;
|
|
|
|
const updatedChild: Task = {
|
|
...childTask,
|
|
sourceParentTaskId: undefined,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
this.db.prepare("UPDATE tasks SET sourceParentTaskId = NULL, updatedAt = ? WHERE id = ?").run(updatedChild.updatedAt, updatedChild.id);
|
|
if (this.isWatching) {
|
|
this.taskCache.set(updatedChild.id, updatedChild);
|
|
}
|
|
rewrittenChildren.push(updatedChild);
|
|
}
|
|
|
|
return rewrittenChildren;
|
|
}
|
|
|
|
/**
|
|
* 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" | "branch-group-integration" | "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 normalizedIntegrationBranch =
|
|
typeof settings.integrationBranch === "string" ? settings.integrationBranch.trim() : "";
|
|
const normalizedBaseBranch = typeof settings.baseBranch === "string" ? settings.baseBranch.trim() : "";
|
|
let projectDefaultBranch =
|
|
normalizedIntegrationBranch.length > 0
|
|
? normalizedIntegrationBranch
|
|
: normalizedBaseBranch.length > 0
|
|
? normalizedBaseBranch
|
|
: "";
|
|
if (!projectDefaultBranch) {
|
|
const originHead = await this.runGitCommand("git symbolic-ref --short refs/remotes/origin/HEAD", 5_000);
|
|
if (originHead.exitCode === 0) {
|
|
projectDefaultBranch = originHead.stdout
|
|
.trim()
|
|
.replace(/^refs\/heads\//, "")
|
|
.replace(/^refs\/remotes\/origin\//, "")
|
|
.replace(/^origin\//, "");
|
|
}
|
|
}
|
|
const mergeTarget = resolveTaskMergeTarget(task, {
|
|
projectDefaultBranch: projectDefaultBranch || undefined,
|
|
});
|
|
|
|
// 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(options?: { removeLineageReferences?: boolean }): 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, {
|
|
cleanup: true,
|
|
removeLineageReferences: options?.removeLineageReferences,
|
|
})
|
|
)
|
|
);
|
|
|
|
return archivedTasks;
|
|
}
|
|
|
|
/**
|
|
* Archive a done task (move from done → archived).
|
|
* Logs the action and emits `task:moved` event.
|
|
* @param optionsOrCleanup - Boolean cleanup flag for backward compatibility,
|
|
* or an options object that also allows removeLineageReferences.
|
|
*/
|
|
async archiveTask(
|
|
id: string,
|
|
optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: 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'`,
|
|
);
|
|
}
|
|
|
|
const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false;
|
|
const removeLineageReferences = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true;
|
|
const lineageChildIds = this.findLiveLineageChildren(id);
|
|
if (lineageChildIds.length > 0 && !removeLineageReferences) {
|
|
throw new TaskHasLineageChildrenError(id, lineageChildIds);
|
|
}
|
|
|
|
task.column = "archived";
|
|
task.columnMovedAt = new Date().toISOString();
|
|
task.updatedAt = task.columnMovedAt;
|
|
task.log.push({
|
|
timestamp: task.columnMovedAt,
|
|
action: "Task archived",
|
|
});
|
|
|
|
let rewrittenLineageChildren: Task[] = [];
|
|
|
|
if (!cleanup) {
|
|
this.db.transaction(() => {
|
|
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
|
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
|
if (rewrittenLineageChildren.length > 0) {
|
|
this.db.bumpLastModified();
|
|
}
|
|
});
|
|
|
|
await this.atomicWriteTaskJson(dir, task);
|
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
|
for (const lineageChild of rewrittenLineageChildren) {
|
|
this.emit("task:updated", lineageChild);
|
|
}
|
|
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.db.transaction(() => {
|
|
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
|
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
|
this.purgeTaskWorkflowSelectionRows(id);
|
|
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);
|
|
}
|
|
|
|
for (const lineageChild of rewrittenLineageChildren) {
|
|
this.emit("task:updated", lineageChild);
|
|
}
|
|
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 state that should not reappear
|
|
// after unarchiving.
|
|
this.clearDoneTransientFields(task);
|
|
|
|
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.paused !== undefined
|
|
|| task.userPaused !== undefined
|
|
|| task.pausedByAgentId !== undefined
|
|
|| task.pausedReason !== undefined;
|
|
|
|
task.status = undefined;
|
|
task.error = undefined;
|
|
task.worktree = undefined;
|
|
task.blockedBy = undefined;
|
|
task.overlapBlockedBy = undefined;
|
|
task.recoveryRetryCount = undefined;
|
|
task.nextRecoveryAt = undefined;
|
|
task.paused = undefined;
|
|
task.userPaused = undefined;
|
|
task.pausedByAgentId = undefined;
|
|
task.pausedReason = 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 });
|
|
}
|
|
|
|
if (!this.donePauseBackfillDone) {
|
|
const repairedTaskIds: string[] = [];
|
|
for (const [taskId, cachedTask] of this.taskCache.entries()) {
|
|
if (cachedTask.column !== "done") continue;
|
|
|
|
const taskDir = this.taskDir(taskId);
|
|
const raw = await readFile(join(taskDir, "task.json"), "utf-8");
|
|
const diskTask = JSON.parse(raw) as Task;
|
|
if (!this.clearDoneTransientFields(diskTask)) continue;
|
|
|
|
await this.atomicWriteTaskJson(taskDir, diskTask);
|
|
this.taskCache.set(taskId, { ...diskTask });
|
|
repairedTaskIds.push(taskId);
|
|
}
|
|
this.donePauseBackfillDone = true;
|
|
|
|
storeLog.log("done-task pause metadata backfill completed", {
|
|
phase: "watch:done-pause-backfill",
|
|
repairedCount: repairedTaskIds.length,
|
|
repairedTaskIds: repairedTaskIds.slice(0, 20),
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
this.suppressActivityLogForPollingEmit = true;
|
|
try {
|
|
if (archivedSet.has(id)) {
|
|
// Task moved to archive — emit task:moved (matching what
|
|
// archiveTask emits in-process) so other subscribers can react.
|
|
// Skip already-archived cache entries to avoid no-op emits.
|
|
// Activity-log listeners skip polling emits; the originating
|
|
// TaskStore instance wrote the row in-process.
|
|
if (cached.column !== "archived") {
|
|
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" });
|
|
}
|
|
} else {
|
|
// Polling replicas only mirror the originating delete signal.
|
|
// Do not record run-audit here; the writer already owns that row.
|
|
this.emit("task:deleted", cached);
|
|
}
|
|
} finally {
|
|
this.suppressActivityLogForPollingEmit = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
this.suppressActivityLogForPollingEmit = true;
|
|
try {
|
|
if (task.deletedAt) {
|
|
if (cached) {
|
|
this.taskCache.delete(task.id);
|
|
// Polling replicas only re-emit task:deleted for subscribers.
|
|
// They must not insert duplicate run-audit rows cross-instance.
|
|
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);
|
|
}
|
|
} finally {
|
|
this.suppressActivityLogForPollingEmit = false;
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Buffer an agent log entry for file-backed persistence.
|
|
* 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 to per-task JSONL files.
|
|
* 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;
|
|
|
|
const batch = this.agentLogBuffer.slice();
|
|
const flushCount = batch.length;
|
|
|
|
let validEntries = batch;
|
|
const flushedEntries = new Set<typeof batch[number]>();
|
|
try {
|
|
const liveTaskIds = new Set(
|
|
(this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id),
|
|
);
|
|
validEntries = batch.filter((entry) => liveTaskIds.has(entry.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 citationInputs: GoalCitationInput[] = [];
|
|
const entriesByTask = new Map<string, typeof validEntries>();
|
|
for (const entry of validEntries) {
|
|
const taskEntries = entriesByTask.get(entry.taskId);
|
|
if (taskEntries) {
|
|
taskEntries.push(entry);
|
|
} else {
|
|
entriesByTask.set(entry.taskId, [entry]);
|
|
}
|
|
}
|
|
|
|
for (const [taskId, taskEntries] of entriesByTask) {
|
|
const appended = appendAgentLogEntriesSync(this.taskDir(taskId), taskEntries);
|
|
taskEntries.forEach((entry) => flushedEntries.add(entry));
|
|
for (const entry of appended) {
|
|
try {
|
|
citationInputs.push(
|
|
...this.scanAndRecordCitations(
|
|
entry.text,
|
|
"agent_log",
|
|
entry.sourceRef,
|
|
entry.agent ?? "unknown",
|
|
entry.taskId,
|
|
entry.timestamp,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
console.warn("[fusion] Failed to scan goal citations from agent_log:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (citationInputs.length > 0) {
|
|
try {
|
|
this.recordGoalCitations(citationInputs);
|
|
} catch (err) {
|
|
console.warn("[fusion] Failed to record goal citations from agent_log batch:", err);
|
|
}
|
|
}
|
|
this.db.bumpLastModified();
|
|
}
|
|
} finally {
|
|
this.agentLogBuffer.splice(0, flushCount);
|
|
const remainingValidEntries = validEntries.filter((entry) => !flushedEntries.has(entry));
|
|
if (remainingValidEntries.length > 0) {
|
|
this.agentLogBuffer.unshift(...remainingValidEntries);
|
|
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 liveTaskIds = new Set(
|
|
(this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id),
|
|
);
|
|
const validEntries = normalizedEntries.filter((entry) => liveTaskIds.has(entry.taskId));
|
|
const dropped = normalizedEntries.length - validEntries.length;
|
|
if (dropped > 0) {
|
|
console.warn(`[fusion] Dropped ${dropped} batch agent log entries for deleted tasks (${this.db.path})`);
|
|
}
|
|
|
|
const citationInputs: GoalCitationInput[] = [];
|
|
const entriesByTask = new Map<string, typeof validEntries>();
|
|
for (const entry of validEntries) {
|
|
const taskEntries = entriesByTask.get(entry.taskId);
|
|
if (taskEntries) {
|
|
taskEntries.push(entry);
|
|
} else {
|
|
entriesByTask.set(entry.taskId, [entry]);
|
|
}
|
|
}
|
|
|
|
for (const [taskId, taskEntries] of entriesByTask) {
|
|
const appended = appendAgentLogEntriesSync(
|
|
this.taskDir(taskId),
|
|
taskEntries.map((entry) => ({
|
|
timestamp,
|
|
taskId: entry.taskId,
|
|
text: entry.text,
|
|
type: entry.type,
|
|
detail: entry.detail ?? null,
|
|
agent: entry.agent ?? null,
|
|
})),
|
|
);
|
|
for (const entry of appended) {
|
|
try {
|
|
citationInputs.push(
|
|
...this.scanAndRecordCitations(
|
|
entry.text,
|
|
"agent_log",
|
|
entry.sourceRef,
|
|
entry.agent ?? "unknown",
|
|
entry.taskId,
|
|
entry.timestamp,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
console.warn("[fusion] Failed to scan goal citations from agent log batch:", err);
|
|
}
|
|
}
|
|
}
|
|
if (citationInputs.length > 0) {
|
|
try {
|
|
this.recordGoalCitations(citationInputs);
|
|
} catch (err) {
|
|
console.warn("[fusion] Failed to record goal citations from appendAgentLogBatch:", err);
|
|
}
|
|
}
|
|
if (validEntries.length > 0) {
|
|
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 }),
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private hasActiveTask(taskId: string): boolean {
|
|
const row = this.db.prepare(`SELECT id FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(taskId) as
|
|
| { id: string }
|
|
| undefined;
|
|
return Boolean(row);
|
|
}
|
|
|
|
/**
|
|
* List all current task documents for a task, ordered by key.
|
|
*/
|
|
async getTaskDocuments(taskId: string): Promise<TaskDocument[]> {
|
|
if (!this.hasActiveTask(taskId)) {
|
|
return [];
|
|
}
|
|
|
|
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
|
|
WHERE t.${TaskStore.ACTIVE_TASKS_WHERE}
|
|
`;
|
|
const params: (string | number)[] = [];
|
|
|
|
if (options?.searchQuery && options.searchQuery.trim() !== "") {
|
|
const query = `%${options.searchQuery.trim()}%`;
|
|
sql += ` AND (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> {
|
|
if (!this.hasActiveTask(taskId)) {
|
|
return 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);
|
|
|
|
try {
|
|
const citationInputs = this.scanAndRecordCitations(
|
|
input.content,
|
|
"task_document",
|
|
`document:${taskId}:${input.key}:rev${document.revision}`,
|
|
input.author ?? "user",
|
|
taskId,
|
|
document.updatedAt,
|
|
);
|
|
if (citationInputs.length > 0) {
|
|
this.recordGoalCitations(citationInputs);
|
|
}
|
|
} catch (err) {
|
|
console.warn("[fusion] Failed to scan/record goal citations from task document:", err);
|
|
}
|
|
|
|
return document;
|
|
}
|
|
|
|
/**
|
|
* List archived revisions for a task document, newest first.
|
|
*/
|
|
async getTaskDocumentRevisions(
|
|
taskId: string,
|
|
key: string,
|
|
options?: { limit?: number },
|
|
): Promise<TaskDocumentRevision[]> {
|
|
if (!this.hasActiveTask(taskId)) {
|
|
return [];
|
|
}
|
|
|
|
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.
|
|
* Read paths gate on the parent task's active state, but deletes remain allowed
|
|
* for forensic cleanup against soft-deleted parents.
|
|
*/
|
|
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 = this.readTaskFromDb(taskId, { includeDeleted: true });
|
|
if (task && task.deletedAt == null) {
|
|
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 JSONL storage.
|
|
* 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();
|
|
if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) {
|
|
return [];
|
|
}
|
|
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 [];
|
|
|
|
return readAgentLogEntries(this.taskDir(taskId), { limit, offset }).map(
|
|
({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Count total number of persisted agent log entries for a task in JSONL storage.
|
|
*
|
|
* @param taskId - The task ID (e.g. "KB-001")
|
|
* @returns Total number of log entries
|
|
*/
|
|
async getAgentLogCount(taskId: string): Promise<number> {
|
|
this.flushAgentLogBuffer();
|
|
if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) {
|
|
return 0;
|
|
}
|
|
return countAgentLogEntries(this.taskDir(taskId));
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) {
|
|
return [];
|
|
}
|
|
const end = endIso ?? new Date().toISOString();
|
|
return readAgentLogEntriesByTimeRange(this.taskDir(taskId), startIso, end).map(
|
|
({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry,
|
|
);
|
|
}
|
|
|
|
async importLegacyAgentLogs(): Promise<number> {
|
|
if (!existsSync(this.tasksDir)) return 0;
|
|
|
|
const entries = await readdir(this.tasksDir, { withFileTypes: true });
|
|
let imported = 0;
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const taskDir = join(this.tasksDir, entry.name);
|
|
const logPath = join(taskDir, "agent.log");
|
|
if (!existsSync(logPath)) continue;
|
|
|
|
try {
|
|
const content = await readFile(logPath, "utf-8");
|
|
const parsedEntries: Array<{
|
|
timestamp: string;
|
|
taskId: string;
|
|
text: string;
|
|
type: AgentLogEntry["type"];
|
|
detail?: string | null;
|
|
agent?: AgentLogEntry["agent"] | null;
|
|
}> = [];
|
|
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;
|
|
|
|
parsedEntries.push({
|
|
timestamp,
|
|
taskId: parsedTaskId,
|
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
type: type as AgentLogEntry["type"],
|
|
detail: typeof parsed.detail === "string" ? parsed.detail : null,
|
|
agent: typeof parsed.agent === "string" ? (parsed.agent as AgentLogEntry["agent"]) : null,
|
|
});
|
|
} catch {
|
|
// Skip malformed JSONL lines.
|
|
}
|
|
}
|
|
|
|
appendAgentLogEntriesSync(taskDir, parsedEntries);
|
|
imported += parsedEntries.length;
|
|
} 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();
|
|
}
|
|
|
|
/**
|
|
* One-time migration: copy `agentLogEntries` rows from SQLite into per-task
|
|
* JSONL files, then rewrite goal-citation source-refs from the old
|
|
* `agentLog:<rowid>` format to the new `agentLog:{taskId}:{lineNo}` format.
|
|
* Guarded by `__meta` so it runs exactly once.
|
|
*/
|
|
private async migrateAgentLogEntriesToFilesOnce(): Promise<void> {
|
|
const migrationKey = "agentLogEntriesToFileMigrationVersion";
|
|
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;
|
|
}
|
|
|
|
// Only run if the agentLogEntries table still exists
|
|
const hasTable =
|
|
this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1").get() !==
|
|
undefined;
|
|
if (!hasTable) {
|
|
// Table already gone (fresh DB or already migrated) — mark done
|
|
this.db.prepare(`
|
|
INSERT INTO __meta (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`).run(migrationKey, migrationVersion);
|
|
return;
|
|
}
|
|
|
|
interface AgentLogRow {
|
|
id: number;
|
|
taskId: string;
|
|
timestamp: string;
|
|
text: string;
|
|
type: string;
|
|
detail: string | null;
|
|
agent: string | null;
|
|
}
|
|
|
|
// Read all rows ordered by taskId, id so each task's entries are
|
|
// written in their original insertion order
|
|
const rows = this.db
|
|
.prepare("SELECT id, taskId, timestamp, text, type, detail, agent FROM agentLogEntries ORDER BY taskId, id")
|
|
.all() as AgentLogRow[];
|
|
|
|
if (rows.length > 0) {
|
|
// Group rows by task
|
|
const entriesByTask = new Map<string, AgentLogRow[]>();
|
|
for (const row of rows) {
|
|
let taskRows = entriesByTask.get(row.taskId);
|
|
if (!taskRows) {
|
|
taskRows = [];
|
|
entriesByTask.set(row.taskId, taskRows);
|
|
}
|
|
taskRows.push(row);
|
|
}
|
|
|
|
// Write per-task JSONL files
|
|
const rowIdToNewRef = new Map<number, string>();
|
|
for (const [taskId, taskRows] of entriesByTask) {
|
|
const td = this.taskDir(taskId);
|
|
const appended = appendAgentLogEntriesSync(
|
|
td,
|
|
taskRows.map((r) => ({
|
|
timestamp: r.timestamp,
|
|
taskId: r.taskId,
|
|
text: r.text,
|
|
type: r.type as AgentLogEntry["type"],
|
|
detail: r.detail,
|
|
agent: r.agent as AgentLogEntry["agent"] | null,
|
|
})),
|
|
);
|
|
// Build mapping from old rowid to new sourceRef
|
|
for (let i = 0; i < taskRows.length; i++) {
|
|
rowIdToNewRef.set(taskRows[i]!.id, appended[i]!.sourceRef);
|
|
}
|
|
}
|
|
|
|
// Rewrite goal-citation source-refs that use the old agentLog:<rowid> format
|
|
const oldFormatRows = this.db
|
|
.prepare("SELECT id, sourceRef FROM goal_citations WHERE surface = 'agent_log' AND sourceRef GLOB 'agentLog:[0-9]*'")
|
|
.all() as Array<{ id: number; sourceRef: string }>;
|
|
|
|
const updateStmt = this.db.prepare("UPDATE goal_citations SET sourceRef = ? WHERE id = ?");
|
|
this.db.transaction(() => {
|
|
for (const citation of oldFormatRows) {
|
|
const oldRowId = parseInt(citation.sourceRef.replace("agentLog:", ""), 10);
|
|
const newRef = rowIdToNewRef.get(oldRowId);
|
|
if (newRef) {
|
|
updateStmt.run(newRef, citation.id);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mark migration as done
|
|
this.db.prepare(`
|
|
INSERT INTO __meta (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`).run(migrationKey, migrationVersion);
|
|
this.db.bumpLastModified();
|
|
}
|
|
|
|
private async cleanupNoOpTaskMovedActivityRowsOnce(): Promise<void> {
|
|
const migrationKey = "noOpTaskMovedActivityCleanupVersion";
|
|
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;
|
|
}
|
|
|
|
const hasTable =
|
|
this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activityLog' LIMIT 1").get() !==
|
|
undefined;
|
|
const markDone = () => {
|
|
this.db.prepare(`
|
|
INSERT INTO __meta (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`).run(migrationKey, migrationVersion);
|
|
};
|
|
|
|
if (!hasTable) {
|
|
markDone();
|
|
this.db.bumpLastModified();
|
|
return;
|
|
}
|
|
|
|
this.db.transactionImmediate(() => {
|
|
this.db.prepare(`
|
|
DELETE FROM activityLog
|
|
WHERE type = 'task:moved'
|
|
AND json_extract(metadata, '$.from') = json_extract(metadata, '$.to')
|
|
`).run();
|
|
markDone();
|
|
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.purgeTaskWorkflowSelectionRows(task.id);
|
|
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.
|
|
*
|
|
* Note: lineage pointers to archived/deleted parents are tolerated here.
|
|
* This cleanup runs on already-archived rows, and lineage integrity gates
|
|
* are enforced earlier on deleteTask/archiveTask for live children only.
|
|
*/
|
|
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.purgeTaskWorkflowSelectionRows(task.id);
|
|
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 sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
|
|
if (sanitizedPrompt.dropped.length > 0) {
|
|
storeLog.log(`[file-scope-sanitize] restore ${entry.id}: dropped=[${sanitizedPrompt.dropped.join(",")}]`);
|
|
}
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, "PROMPT.md"), sanitizedPrompt.sanitized);
|
|
|
|
// 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)))
|
|
// Steps materialized by compiling a workflow are an execution detail; keep
|
|
// them out of the user-facing step manager listing. The executor resolves
|
|
// them directly via getWorkflowStep, which is unaffected by this filter.
|
|
.filter((step) => !step.templateId?.startsWith(WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX));
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── Workflow definitions (named WorkflowIr graphs) ─────────────────────
|
|
|
|
/** Allocate the next workflow-definition id (WF-001, WF-002, …) using a
|
|
* monotonic counter persisted in __meta. Never reuses ids across deletes. */
|
|
private nextWorkflowDefinitionId(): string {
|
|
// Serialize the read+increment in one write transaction so two TaskStore
|
|
// instances cannot both observe the same counter and allocate the same
|
|
// WF-id (which would collide on the workflows primary key).
|
|
return this.db.transactionImmediate(() => {
|
|
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'nextWorkflowDefinitionId'").get() as
|
|
| { value: string }
|
|
| undefined;
|
|
const next = row ? parseInt(row.value, 10) || 1 : 1;
|
|
this.db
|
|
.prepare(
|
|
"INSERT INTO __meta (key, value) VALUES ('nextWorkflowDefinitionId', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
)
|
|
.run(String(next + 1));
|
|
return `WF-${String(next).padStart(3, "0")}`;
|
|
});
|
|
}
|
|
|
|
private toWorkflowDefinition(row: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
ir: string;
|
|
layout: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}): WorkflowDefinition {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
ir: parseWorkflowIr(row.ir),
|
|
layout: this.parseWorkflowLayout(row.layout),
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
private parseWorkflowLayout(
|
|
raw: string,
|
|
): Record<string, WorkflowNodeLayout> {
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, WorkflowNodeLayout>;
|
|
}
|
|
} catch {
|
|
// Corrupt layout JSON falls back to empty (auto-layout) rather than failing the read.
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/** Server-side trait-composition validation (residual A). Throws a typed
|
|
* ColumnTraitValidationError when the IR's columns have save-blocking trait
|
|
* conflicts, so conflicts reject server-side and not only in the editor. A
|
|
* v1 IR (no columns) is a no-op. */
|
|
private assertWorkflowIrTraitsValid(ir: WorkflowIr): void {
|
|
const columns = (ir as { columns?: WorkflowIrColumn[] }).columns;
|
|
if (Array.isArray(columns) && columns.length > 0) {
|
|
assertColumnTraitsValid(columns);
|
|
}
|
|
}
|
|
|
|
/** Create a named workflow definition. The IR is validated via parseWorkflowIr. */
|
|
async createWorkflowDefinition(
|
|
input: WorkflowDefinitionInput,
|
|
): Promise<WorkflowDefinition> {
|
|
return this.withConfigLock(async () => {
|
|
const name = input.name?.trim();
|
|
if (!name) throw new Error("Workflow name is required");
|
|
// Validate the IR shape up front so we never persist a malformed graph.
|
|
const ir = parseWorkflowIr(input.ir);
|
|
// Residual A: also reject save-blocking trait composition conflicts here,
|
|
// not only in the editor's client-side validation.
|
|
this.assertWorkflowIrTraitsValid(ir);
|
|
const layout = input.layout ?? {};
|
|
const now = new Date().toISOString();
|
|
const id = this.nextWorkflowDefinitionId();
|
|
const definition: WorkflowDefinition = {
|
|
id,
|
|
name,
|
|
description: input.description ?? "",
|
|
ir,
|
|
layout,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO workflows (id, name, description, ir, layout, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
definition.id,
|
|
definition.name,
|
|
definition.description,
|
|
serializeWorkflowIr(definition.ir),
|
|
JSON.stringify(definition.layout),
|
|
definition.createdAt,
|
|
definition.updatedAt,
|
|
);
|
|
|
|
this.workflowDefinitionsCache = null;
|
|
this.db.bumpLastModified();
|
|
return definition;
|
|
});
|
|
}
|
|
|
|
/** List all workflow definitions, oldest first. Cached until a mutation. */
|
|
async listWorkflowDefinitions(): Promise<WorkflowDefinition[]> {
|
|
if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache;
|
|
const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
ir: string;
|
|
layout: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}>;
|
|
// Built-in templates lead the list and cannot be edited/deleted.
|
|
this.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => this.toWorkflowDefinition(row))];
|
|
return this.workflowDefinitionsCache;
|
|
}
|
|
|
|
/** Get a single workflow definition by id, or undefined when absent. */
|
|
async getWorkflowDefinition(
|
|
id: string,
|
|
): Promise<WorkflowDefinition | undefined> {
|
|
const builtin = getBuiltinWorkflow(id);
|
|
if (builtin) return builtin;
|
|
const row = this.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as
|
|
| {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
ir: string;
|
|
layout: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
| undefined;
|
|
return row ? this.toWorkflowDefinition(row) : undefined;
|
|
}
|
|
|
|
/** Update a workflow definition. The IR (when supplied) is re-validated. */
|
|
async updateWorkflowDefinition(
|
|
id: string,
|
|
updates: WorkflowDefinitionUpdate,
|
|
): Promise<WorkflowDefinition> {
|
|
if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be edited");
|
|
// U5 (R20): flag-ON edits that remove an occupied column block with a typed
|
|
// OccupiedColumnsError unless `rehomeTo` is supplied. Computed before taking
|
|
// the config lock (pure DB reads) so the lock body stays focused.
|
|
const flagOn = await this.workflowColumnsFlagOn();
|
|
let pendingRehome: { rehomeTo: string; occupantTaskIds: string[] } | undefined;
|
|
if (flagOn && updates.ir !== undefined) {
|
|
const existingForCheck = await this.getWorkflowDefinition(id);
|
|
if (!existingForCheck) throw new Error(`Workflow '${id}' not found`);
|
|
const nextIrForCheck = parseWorkflowIr(updates.ir);
|
|
const occupantsByColumn = this.occupantsByColumnForWorkflow(id, false);
|
|
const removed = computeRemovedOccupiedColumns(
|
|
existingForCheck.ir,
|
|
nextIrForCheck,
|
|
occupantsByColumn,
|
|
);
|
|
if (removed.length > 0) {
|
|
if (updates.rehomeTo === undefined) {
|
|
throw new OccupiedColumnsError(id, removed);
|
|
}
|
|
assertRehomeTargetValid(nextIrForCheck, updates.rehomeTo);
|
|
// Collect the occupant task ids of the removed columns to re-home AFTER
|
|
// the IR save commits, so the cards land in a column the new IR defines.
|
|
const removedSet = new Set(removed.map((r) => r.columnId));
|
|
const occupantTaskIds = this.listWorkflowOccupantTaskIds(id, false).filter((taskId) => {
|
|
const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as
|
|
| { column: string }
|
|
| undefined;
|
|
return row ? removedSet.has(row.column) : false;
|
|
});
|
|
pendingRehome = { rehomeTo: updates.rehomeTo, occupantTaskIds };
|
|
}
|
|
}
|
|
const saved = await this.withConfigLock(async () => {
|
|
const existing = await this.getWorkflowDefinition(id);
|
|
if (!existing) throw new Error(`Workflow '${id}' not found`);
|
|
|
|
const name = updates.name !== undefined ? updates.name.trim() : existing.name;
|
|
if (!name) throw new Error("Workflow name is required");
|
|
const ir = updates.ir !== undefined ? parseWorkflowIr(updates.ir) : existing.ir;
|
|
// Residual A: reject save-blocking trait composition conflicts server-side
|
|
// when the IR is being changed.
|
|
if (updates.ir !== undefined) this.assertWorkflowIrTraitsValid(ir);
|
|
const next: WorkflowDefinition = {
|
|
...existing,
|
|
name,
|
|
description: updates.description !== undefined ? updates.description : existing.description,
|
|
ir,
|
|
layout: updates.layout !== undefined ? updates.layout : existing.layout,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
this.db
|
|
.prepare(
|
|
`UPDATE workflows SET name = ?, description = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`,
|
|
)
|
|
.run(
|
|
next.name,
|
|
next.description,
|
|
serializeWorkflowIr(next.ir),
|
|
JSON.stringify(next.layout),
|
|
next.updatedAt,
|
|
id,
|
|
);
|
|
|
|
this.workflowDefinitionsCache = null;
|
|
this.db.bumpLastModified();
|
|
return next;
|
|
});
|
|
|
|
// U5 (R20): now that the new IR is committed, re-home the occupants of the
|
|
// removed columns into `rehomeTo` (one audit event per card). Done outside
|
|
// the config lock; each rehome takes its own task lock via moveTask.
|
|
if (pendingRehome) {
|
|
for (const taskId of pendingRehome.occupantTaskIds) {
|
|
await this.rehomeOccupant(taskId, pendingRehome.rehomeTo, "workflow-edit-rehome", {
|
|
workflowId: id,
|
|
});
|
|
}
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
/** Delete a workflow definition, cascading to per-task selections, their
|
|
* materialized step rows, and the project default. Throws when the id does
|
|
* not exist. */
|
|
async deleteWorkflowDefinition(id: string): Promise<void> {
|
|
if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be deleted");
|
|
// U5 (R20): flag-ON, capture the occupant task ids BEFORE the cascade clears
|
|
// their selection rows, so we can re-home them to the DEFAULT workflow's
|
|
// entry column once their selection resolves back to the default (KTD-1).
|
|
const flagOn = await this.workflowColumnsFlagOn();
|
|
const occupantTaskIds = flagOn ? this.listWorkflowOccupantTaskIds(id, false) : [];
|
|
const deleted = this.db.prepare("DELETE FROM workflows WHERE id = ?").run(id) as { changes?: number };
|
|
if ((deleted.changes || 0) === 0) {
|
|
throw new Error(`Workflow '${id}' not found`);
|
|
}
|
|
this.workflowDefinitionsCache = null;
|
|
|
|
// Cascade: clear the project default when it pointed at this workflow.
|
|
try {
|
|
if ((await this.getDefaultWorkflowId()) === id) {
|
|
await this.setDefaultWorkflowId(null);
|
|
}
|
|
} catch {
|
|
// Best-effort: a dangling default falls back gracefully at task creation.
|
|
}
|
|
|
|
// Cascade: drop selections referencing this workflow, their materialized
|
|
// step rows, and reset the affected tasks' enabled steps.
|
|
const selections = this.db
|
|
.prepare("SELECT taskId, stepIds FROM task_workflow_selection WHERE workflowId = ?")
|
|
.all(id) as Array<{ taskId: string; stepIds: string }>;
|
|
for (const row of selections) {
|
|
try {
|
|
const stepIds = JSON.parse(row.stepIds) as unknown;
|
|
if (Array.isArray(stepIds)) {
|
|
for (const stepId of stepIds) {
|
|
if (typeof stepId === "string") {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Corrupt stepIds list — still remove the selection row below.
|
|
}
|
|
this.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(row.taskId);
|
|
try {
|
|
await this.updateTask(row.taskId, { enabledWorkflowSteps: [] });
|
|
} catch {
|
|
// Task may be deleted/archived; dangling step ids resolve to undefined
|
|
// at execution time and are skipped.
|
|
}
|
|
}
|
|
if (selections.length > 0) this.workflowStepsCache = null;
|
|
this.db.bumpLastModified();
|
|
|
|
// U5 (R20) delete reconciliation: re-home each occupant to the default
|
|
// workflow's entry column. Their selection rows are already cleared above,
|
|
// so they now resolve to the built-in default workflow (KTD-1); the re-home
|
|
// move preserves task fields (preserveProgress) and emits one audit per card.
|
|
if (flagOn && occupantTaskIds.length > 0) {
|
|
const defaultEntry = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR);
|
|
if (defaultEntry) {
|
|
for (const taskId of occupantTaskIds) {
|
|
await this.rehomeOccupant(taskId, defaultEntry, "workflow-delete", { workflowId: id });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ──────────
|
|
//
|
|
// These helpers are only consulted when the `workflowColumns` flag is ON; the
|
|
// flag-OFF CRUD paths above keep their exact current behavior. Re-homing moves
|
|
// always route through `moveTask` with `moveSource: "engine"` + `bypassGuards`
|
|
// (a recovery-class move, KTD-9) — never a raw column write — so capacity
|
|
// (KTD-10) and the single transition authority (KTD-3) are honored.
|
|
|
|
/** True when the `workflowColumns` flag is ON (merged global + project). */
|
|
private async workflowColumnsFlagOn(): Promise<boolean> {
|
|
return isWorkflowColumnsEnabled(await this.getSettingsFast());
|
|
}
|
|
|
|
/** The active (non-deleted) task ids currently selecting `workflowId`. A
|
|
* built-in/default workflow additionally owns every task with NO selection
|
|
* row (null selection resolves to the default workflow, KTD-1). */
|
|
private listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): string[] {
|
|
const ids: string[] = [];
|
|
const selected = this.db
|
|
.prepare(
|
|
`SELECT s.taskId AS taskId FROM task_workflow_selection s
|
|
JOIN tasks t ON t.id = s.taskId
|
|
WHERE s.workflowId = ? AND t."deletedAt" IS NULL`,
|
|
)
|
|
.all(workflowId) as Array<{ taskId: string }>;
|
|
for (const row of selected) ids.push(row.taskId);
|
|
if (includeNullSelection) {
|
|
const unselected = this.db
|
|
.prepare(
|
|
`SELECT t.id AS id FROM tasks t
|
|
WHERE t."deletedAt" IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM task_workflow_selection s WHERE s.taskId = t.id)`,
|
|
)
|
|
.all() as Array<{ id: string }>;
|
|
for (const row of unselected) ids.push(row.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/** Map column id → occupant count for the tasks selecting `workflowId`
|
|
* (plus null-selection tasks when `includeNullSelection`). */
|
|
private occupantsByColumnForWorkflow(
|
|
workflowId: string,
|
|
includeNullSelection: boolean,
|
|
): Map<string, number> {
|
|
const counts = new Map<string, number>();
|
|
for (const taskId of this.listWorkflowOccupantTaskIds(workflowId, includeNullSelection)) {
|
|
const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as
|
|
| { column: string }
|
|
| undefined;
|
|
if (!row) continue;
|
|
counts.set(row.column, (counts.get(row.column) ?? 0) + 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
/** Re-home a single occupant to `targetColumn` via an engine-sourced,
|
|
* guard-bypassing recovery move, aborting in-flight work first, and emit one
|
|
* audit event. Best-effort per card: a failure is audited and skipped so one
|
|
* stuck card never blocks the rest of the batch. */
|
|
private async rehomeOccupant(
|
|
taskId: string,
|
|
targetColumn: string,
|
|
reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome",
|
|
metadata: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const current = this.readTaskFromDb(taskId, { includeDeleted: false });
|
|
if (!current) return;
|
|
const fromColumn = current.column;
|
|
if (fromColumn === targetColumn) {
|
|
// Already in the target column — nothing to move, but still record the
|
|
// reconciliation decision for audit traceability.
|
|
this.recordRunAuditEvent({
|
|
taskId,
|
|
agentId: "system",
|
|
runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`,
|
|
domain: "database",
|
|
mutationType: "task:workflow-reconcile",
|
|
target: taskId,
|
|
metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, moved: false },
|
|
});
|
|
return;
|
|
}
|
|
const abortRan = await runReconciliationAbort({ taskId, fromColumn, reason });
|
|
let moved = false;
|
|
let error: string | undefined;
|
|
try {
|
|
// Recovery-class move: engine source + bypassGuards (KTD-9). preserveProgress
|
|
// keeps the task's fields intact (R20 delete semantics). Capacity (KTD-10) is
|
|
// NOT bypassed — a full target column rejects, which we audit and skip.
|
|
await this.moveTask(taskId, targetColumn as Column, {
|
|
moveSource: "engine",
|
|
bypassGuards: true,
|
|
recoveryRehome: true,
|
|
preserveProgress: true,
|
|
preserveResumeState: true,
|
|
preserveWorktree: true,
|
|
allowDirectInReviewMove: true,
|
|
});
|
|
moved = true;
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
}
|
|
this.recordRunAuditEvent({
|
|
taskId,
|
|
agentId: "system",
|
|
runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`,
|
|
domain: "database",
|
|
mutationType: "task:workflow-reconcile",
|
|
target: taskId,
|
|
metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, abortRan, moved, error },
|
|
});
|
|
}
|
|
|
|
// ── U12: workflow-columns integrity pass ──────────────────────────────────
|
|
//
|
|
// Migration rewrites ZERO task rows (KTD-1): a null selection resolves to the
|
|
// built-in default workflow at read time, and the default workflow's column
|
|
// IDs are byte-identical to the legacy enum values, so every legacy row is
|
|
// already valid. The only residual risk is a task whose stored column is not a
|
|
// valid column in its RESOLVED workflow — e.g. a custom workflow was edited to
|
|
// drop a column out-of-band, or a legacy row references a column the selected
|
|
// custom workflow never defined. The integrity pass audits those and re-homes
|
|
// them via the U5 reconciliation path (`recoveryRehome`, guard-bypassing,
|
|
// capacity-honoring), one audit event per card.
|
|
//
|
|
// Idempotent: a second run finds nothing out-of-place (the re-home lands the
|
|
// card in a valid column) and is a pure no-op. Tasks in complete- or
|
|
// archived-flagged columns are left UNTOUCHED (done/archived cards are terminal
|
|
// — re-homing them would corrupt the board) even if (defensively) their column
|
|
// were somehow not in the resolved IR; we never disturb terminal cards.
|
|
//
|
|
// Runs only when the `workflowColumns` flag is ON (flag-OFF keeps the legacy
|
|
// enum path, where every column is valid by construction).
|
|
async runWorkflowColumnsIntegrityPass(): Promise<{ scanned: number; rehomed: number; skippedTerminal: number }> {
|
|
let scanned = 0;
|
|
let rehomed = 0;
|
|
let skippedTerminal = 0;
|
|
|
|
const rows = this.db
|
|
.prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`)
|
|
.all() as Array<{ id: string }>;
|
|
|
|
const registry = getTraitRegistry();
|
|
|
|
for (const { id } of rows) {
|
|
scanned += 1;
|
|
const task = this.readTaskFromDb(id, { includeDeleted: false });
|
|
if (!task) continue;
|
|
const ir = this.resolveTaskWorkflowIrSync(id);
|
|
const currentColumn = task.column;
|
|
|
|
// Already valid in its resolved workflow — nothing to do (the common case;
|
|
// this is why the pass is idempotent and a no-op for healthy DBs).
|
|
if (workflowHasColumn(ir, currentColumn)) continue;
|
|
|
|
// The stored column is not in the resolved workflow. Before re-homing,
|
|
// never disturb a terminal card: if the column the card sits in carries a
|
|
// complete/archived flag in its workflow it is terminal — but since the
|
|
// column is NOT in the IR we cannot read its flags there. Fall back to the
|
|
// legacy terminal semantics (done/archived) so terminal cards are never
|
|
// re-homed, matching the plan's "done/archived untouched" rule.
|
|
const column = findWorkflowColumn(ir, currentColumn);
|
|
const flags = column ? registry.resolveColumnFlags(column) : undefined;
|
|
const isTerminal =
|
|
flags?.complete === true ||
|
|
flags?.archived === true ||
|
|
currentColumn === "done" ||
|
|
currentColumn === "archived";
|
|
if (isTerminal) {
|
|
skippedTerminal += 1;
|
|
continue;
|
|
}
|
|
|
|
const targetColumn = resolveEntryColumnId(ir);
|
|
if (!targetColumn) continue; // non-reconcilable IR — leave the card put.
|
|
|
|
await this.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", {
|
|
integrityPass: true,
|
|
invalidColumn: currentColumn,
|
|
});
|
|
rehomed += 1;
|
|
}
|
|
|
|
if (rehomed > 0 || skippedTerminal > 0) {
|
|
storeLog.log("workflowColumns integrity pass completed", {
|
|
phase: "init:workflow-columns-integrity",
|
|
scanned,
|
|
rehomed,
|
|
skippedTerminal,
|
|
});
|
|
}
|
|
return { scanned, rehomed, skippedTerminal };
|
|
}
|
|
|
|
// ── Workflow selection (resolves a workflow to enabledWorkflowSteps) ────
|
|
//
|
|
// Selection never touches the engine's scheduler/executor/merger. It compiles
|
|
// a workflow into WorkflowStep rows and writes their ids into the task's
|
|
// existing `enabledWorkflowSteps`, which the executor already consumes.
|
|
|
|
/** The configured project-default workflow id, or undefined when unset. */
|
|
async getDefaultWorkflowId(): Promise<string | undefined> {
|
|
const settings = await this.getSettingsFast();
|
|
const id = (settings as { defaultWorkflowId?: string }).defaultWorkflowId;
|
|
return id && id.trim() ? id : undefined;
|
|
}
|
|
|
|
/** Set (or clear, with null) the project-default workflow. */
|
|
async setDefaultWorkflowId(workflowId: string | null): Promise<void> {
|
|
if (workflowId) {
|
|
const exists = await this.getWorkflowDefinition(workflowId);
|
|
if (!exists) throw new Error(`Workflow '${workflowId}' not found`);
|
|
}
|
|
// null is updateSettings' explicit-delete sentinel for project keys.
|
|
await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial<Settings>);
|
|
}
|
|
|
|
/** Whether a raw workflow CLI command has been approved (trust-on-first-use).
|
|
* Comparison is on the exact trimmed command string. */
|
|
async isWorkflowCliCommandApproved(command: string): Promise<boolean> {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) return false;
|
|
const settings = await this.getSettings();
|
|
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands;
|
|
return Array.isArray(approved) && approved.includes(trimmed);
|
|
}
|
|
|
|
/** Record approval for a raw workflow CLI command. Idempotent. */
|
|
async approveWorkflowCliCommand(command: string): Promise<void> {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) throw new Error("CLI command is required");
|
|
const settings = await this.getSettings();
|
|
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands ?? [];
|
|
if (approved.includes(trimmed)) return;
|
|
await this.updateSettings({
|
|
approvedWorkflowCliCommands: [...approved, trimmed],
|
|
} as unknown as Partial<Settings>);
|
|
}
|
|
|
|
/** Read the workflow currently selected for a task, if any. */
|
|
/**
|
|
* Synchronously resolve the parsed WorkflowIr that governs a task's columns
|
|
* (U4, flag-ON path). Resolution order:
|
|
* 1. the task's workflow selection (side table) → that workflow's IR;
|
|
* 2. null/missing selection → the built-in default workflow IR (KTD-1).
|
|
* Built-in workflow IRs are resolved from the parsed module constant; custom
|
|
* workflows are read + parsed from the `workflows` row. Pure DB read, safe to
|
|
* call inside `withTaskLock` (no further locks taken). A parse failure or
|
|
* missing custom row falls back to the default workflow so a move is never
|
|
* stranded by a corrupt definition (degraded, not crashed).
|
|
*/
|
|
/**
|
|
* U8 (KTD-2): record a pre-evaluated plugin gate verdict for a move into
|
|
* `toColumn`. Called by the engine's plugin trait adapter AFTER it evaluated
|
|
* the gate (prompt/script) outside the task lock. The flag-ON guard site in
|
|
* `moveTaskInternal` re-checks the recorded verdict in-lock. Verdicts are
|
|
* consumed (cleared) by `consumePluginGateVerdicts` once read so a stale
|
|
* verdict can't silently re-authorize a later move.
|
|
*/
|
|
recordPluginGateVerdict(
|
|
taskId: string,
|
|
toColumn: string,
|
|
verdict: Omit<PluginGateVerdict, "recordedAt"> & { recordedAt?: number },
|
|
): void {
|
|
let byColumn = this.pluginGateVerdicts.get(taskId);
|
|
if (!byColumn) {
|
|
byColumn = new Map();
|
|
this.pluginGateVerdicts.set(taskId, byColumn);
|
|
}
|
|
const list = byColumn.get(toColumn) ?? [];
|
|
// Replace any prior verdict for the same trait (latest evaluation wins).
|
|
const filtered = list.filter((v) => v.traitId !== verdict.traitId);
|
|
filtered.push({ ...verdict, recordedAt: verdict.recordedAt ?? Date.now() });
|
|
byColumn.set(toColumn, filtered);
|
|
}
|
|
|
|
/**
|
|
* U8: read AND clear the recorded plugin gate verdicts for a (task, column).
|
|
* Returns the recorded verdicts (possibly empty). Consuming clears them so the
|
|
* verdict authorizes exactly one move attempt.
|
|
*/
|
|
consumePluginGateVerdicts(taskId: string, toColumn: string): PluginGateVerdict[] {
|
|
const byColumn = this.pluginGateVerdicts.get(taskId);
|
|
if (!byColumn) return [];
|
|
const list = byColumn.get(toColumn) ?? [];
|
|
byColumn.delete(toColumn);
|
|
if (byColumn.size === 0) this.pluginGateVerdicts.delete(taskId);
|
|
return list;
|
|
}
|
|
|
|
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
|
|
const selection = this.getTaskWorkflowSelection(taskId);
|
|
const workflowId = selection?.workflowId;
|
|
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
|
|
if (isBuiltinWorkflowId(workflowId)) {
|
|
const builtin = getBuiltinWorkflow(workflowId);
|
|
return builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
|
|
}
|
|
try {
|
|
const row = this.db
|
|
.prepare("SELECT ir FROM workflows WHERE id = ?")
|
|
.get(workflowId) as { ir: string } | undefined;
|
|
if (!row) return BUILTIN_CODING_WORKFLOW_IR;
|
|
return parseWorkflowIr(row.ir);
|
|
} catch {
|
|
return BUILTIN_CODING_WORKFLOW_IR;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* U6 (KTD-10): the *effective workflow id* used to scope the per-(workflow,
|
|
* column) capacity count. A task with no selection (or a missing/empty
|
|
* selection row) resolves to the built-in default workflow, represented by a
|
|
* stable sentinel so all default-workflow tasks share one capacity pool. A
|
|
* selected workflow id (builtin or custom) is its own pool. Pure DB read; safe
|
|
* inside the move transaction.
|
|
*/
|
|
private resolveEffectiveWorkflowIdSync(taskId: string): string {
|
|
const selection = this.getTaskWorkflowSelection(taskId);
|
|
return selection?.workflowId ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID;
|
|
}
|
|
|
|
/**
|
|
* U6 (KTD-10): count cards currently occupying a (workflow, column) capacity
|
|
* slot, for the in-txn capacity check. Runs INSIDE `moveTaskInternal`'s
|
|
* transaction. A slot is held by a card that:
|
|
* - has committed its column to `targetColumn` (the steady-state holders), OR
|
|
* - (when `countPending`) has a `transitionPending` marker targeting
|
|
* `targetColumn` — it reserved the slot at commit time even though its
|
|
* post-commit hooks haven't finished yet.
|
|
* The moving task itself (`excludeTaskId`) is excluded so a same-column no-op
|
|
* or re-entry never counts itself. Only the candidates in the SAME effective
|
|
* workflow as the mover count (capacity is per-(workflow, column)). Soft-deleted
|
|
* tasks never hold a slot.
|
|
*/
|
|
private countActiveInCapacitySlotSync(params: {
|
|
targetColumn: string;
|
|
workflowId: string;
|
|
countPending: boolean;
|
|
excludeTaskId: string;
|
|
}): number {
|
|
const { targetColumn, workflowId, countPending, excludeTaskId } = params;
|
|
// Candidate rows: in the column now, or (optionally) mid-transition into it.
|
|
// LEFT JOIN the selection row so we can scope by effective workflow id in JS.
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT t.id AS id, t."column" AS col, t.transitionPending AS tp, s.workflowId AS wid
|
|
FROM tasks t
|
|
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
|
|
WHERE t.deletedAt IS NULL
|
|
AND t.id != ?
|
|
AND (t."column" = ? OR (t.transitionPending IS NOT NULL AND t.transitionPending != ''))`,
|
|
)
|
|
.all(excludeTaskId, targetColumn) as Array<{
|
|
id: string;
|
|
col: string;
|
|
tp: string | null;
|
|
wid: string | null;
|
|
}>;
|
|
|
|
let count = 0;
|
|
for (const row of rows) {
|
|
const effectiveWorkflowId = row.wid ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID;
|
|
if (effectiveWorkflowId !== workflowId) continue;
|
|
|
|
if (row.col === targetColumn) {
|
|
count += 1;
|
|
continue;
|
|
}
|
|
// Not committed into the column — only counts if it has reserved the slot
|
|
// via a transitionPending marker targeting this column AND countPending.
|
|
if (!countPending || !row.tp) continue;
|
|
let toColumn: string | undefined;
|
|
try {
|
|
const parsed = JSON.parse(row.tp) as { toColumn?: unknown };
|
|
if (typeof parsed.toColumn === "string") toColumn = parsed.toColumn;
|
|
} catch {
|
|
// Corrupt marker — treat as not holding this slot.
|
|
}
|
|
if (toColumn === targetColumn) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined {
|
|
const row = this.db
|
|
.prepare("SELECT workflowId, stepIds FROM task_workflow_selection WHERE taskId = ?")
|
|
.get(taskId) as { workflowId: string; stepIds: string } | undefined;
|
|
if (!row) return undefined;
|
|
let stepIds: string[] = [];
|
|
try {
|
|
const parsed = JSON.parse(row.stepIds) as unknown;
|
|
if (Array.isArray(parsed)) stepIds = parsed.filter((s): s is string => typeof s === "string");
|
|
} catch {
|
|
// Corrupt list falls back to empty.
|
|
}
|
|
return { workflowId: row.workflowId, stepIds };
|
|
}
|
|
|
|
private writeTaskWorkflowSelection(taskId: string, workflowId: string, stepIds: string[]): void {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(taskId) DO UPDATE SET
|
|
workflowId = excluded.workflowId,
|
|
stepIds = excluded.stepIds,
|
|
updatedAt = excluded.updatedAt`,
|
|
)
|
|
.run(taskId, workflowId, JSON.stringify(stepIds), new Date().toISOString());
|
|
}
|
|
|
|
/** Delete the WorkflowStep rows previously materialized for a task's selection
|
|
* and remove the selection record. Best-effort; safe to call when unset. */
|
|
private removeMaterializedSelection(taskId: string): void {
|
|
const existing = this.getTaskWorkflowSelection(taskId);
|
|
if (existing) {
|
|
for (const stepId of existing.stepIds) {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
}
|
|
this.workflowStepsCache = null;
|
|
}
|
|
this.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(taskId);
|
|
}
|
|
|
|
/** Purge a task's workflow selection and its materialized WorkflowStep rows
|
|
* when the task row itself is being physically removed. `task_workflow_selection`
|
|
* has no FK to `tasks(id)` (SQLite can't add one to an existing table without a
|
|
* rebuild), so deletion must be mirrored here to avoid orphaned selection rows
|
|
* and unreclaimable compiled steps. Best-effort and synchronous: unlike
|
|
* clearTaskWorkflowSelection it does not touch enabledWorkflowSteps, since the
|
|
* owning task row no longer exists. */
|
|
private purgeTaskWorkflowSelectionRows(taskId: string): void {
|
|
const row = this.db
|
|
.prepare("SELECT stepIds FROM task_workflow_selection WHERE taskId = ?")
|
|
.get(taskId) as { stepIds: string } | undefined;
|
|
if (!row) return;
|
|
try {
|
|
const parsed = JSON.parse(row.stepIds) as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
for (const stepId of parsed) {
|
|
if (typeof stepId === "string") {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Corrupt stepIds list — still remove the selection row below.
|
|
}
|
|
this.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(taskId);
|
|
this.workflowStepsCache = null;
|
|
}
|
|
|
|
/** Delete a set of freshly materialized WorkflowStep rows that were never
|
|
* successfully attached to a task/selection (e.g. the owning task create
|
|
* failed). Best-effort; tolerates already-removed ids. */
|
|
private cleanupOrphanedMaterializedSteps(stepIds: string[] | undefined): void {
|
|
if (!stepIds || stepIds.length === 0) return;
|
|
for (const stepId of stepIds) {
|
|
try {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
}
|
|
this.workflowStepsCache = null;
|
|
}
|
|
|
|
/** Persist pre-compiled workflow steps as fresh WorkflowStep rows and return
|
|
* their ids in execution order. Steps are tagged so they stay out of the
|
|
* step manager. Compile via compileWorkflowToSteps before calling. */
|
|
private async materializeWorkflowSteps(
|
|
workflowId: string,
|
|
inputs: import("./types.js").WorkflowStepInput[],
|
|
): Promise<string[]> {
|
|
const ids: string[] = [];
|
|
for (const input of inputs) {
|
|
const step = await this.createWorkflowStep({
|
|
...input,
|
|
templateId: `${WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX}${workflowId}`,
|
|
enabled: true,
|
|
});
|
|
ids.push(step.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/** Resolve the project-default workflow into materialized step ids, or null
|
|
* when no default is set / it is missing / it does not compile. */
|
|
private async materializeDefaultWorkflowSteps(): Promise<{ workflowId: string; stepIds: string[] } | undefined> {
|
|
const workflowId = await this.getDefaultWorkflowId();
|
|
if (!workflowId) return undefined;
|
|
const def = await this.getWorkflowDefinition(workflowId);
|
|
if (!def) return undefined;
|
|
// Compile (and validate) before creating any rows so a non-compilable
|
|
// default falls back cleanly with nothing written.
|
|
const inputs = compileWorkflowToSteps(def.ir);
|
|
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
|
return { workflowId, stepIds };
|
|
}
|
|
|
|
/**
|
|
* Select a workflow for a task: compile it, materialize its steps, and write
|
|
* their ids into the task's enabledWorkflowSteps. Replaces any prior selection
|
|
* (no orphaned steps). Throws WorkflowCompileError for non-linear graphs
|
|
* before any state is written.
|
|
*/
|
|
async selectTaskWorkflow(taskId: string, workflowId: string): Promise<string[]> {
|
|
// Hold the task lock across the whole sequence (materialize → owner write →
|
|
// prior-step cleanup) so it can't interleave with a concurrent select/clear
|
|
// or executor updateTask on the same task. updateTaskUnlocked is used inside
|
|
// because the per-task lock is non-reentrant.
|
|
return this.withTaskLock(taskId, async () => {
|
|
const def = await this.getWorkflowDefinition(workflowId);
|
|
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
|
|
// Compile once up front: a non-linear graph aborts before any mutation.
|
|
const inputs = compileWorkflowToSteps(def.ir);
|
|
|
|
// Materialize the new steps and point the task at them BEFORE deleting the
|
|
// prior selection's rows, so a mid-flight failure never leaves the task
|
|
// referencing already-deleted step ids.
|
|
const priorSelection = this.getTaskWorkflowSelection(taskId);
|
|
const ids = await this.materializeWorkflowSteps(workflowId, inputs);
|
|
try {
|
|
await this.updateTaskUnlocked(taskId, { enabledWorkflowSteps: ids });
|
|
this.writeTaskWorkflowSelection(taskId, workflowId, ids);
|
|
} catch (err) {
|
|
// The owner write (updateTask / selection upsert) failed, so the steps we
|
|
// just materialized would orphan with no selection row pointing at them.
|
|
// Delete them before propagating; the prior selection is left untouched.
|
|
for (const stepId of ids) {
|
|
try {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
} catch {
|
|
// Best-effort cleanup; surface the original error below.
|
|
}
|
|
}
|
|
this.workflowStepsCache = null;
|
|
throw err;
|
|
}
|
|
|
|
if (priorSelection) {
|
|
for (const stepId of priorSelection.stepIds) {
|
|
this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId);
|
|
}
|
|
this.workflowStepsCache = null;
|
|
}
|
|
return ids;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* U5 (R20) workflow switch: select a workflow for a task and, when the
|
|
* `workflowColumns` flag is ON, reconcile the card's board column against the
|
|
* NEW workflow. Same-id column preserves position; otherwise the card re-homes
|
|
* to the new workflow's entry (intake-flagged, else first) column, aborting
|
|
* in-flight processing first (KTD-9). Returns the materialized step ids plus
|
|
* the switch outcome so the dashboard can surface the re-home.
|
|
*
|
|
* Reconciliation runs AFTER `selectTaskWorkflow` releases the per-task lock
|
|
* (moveTask takes its own lock; the per-task lock is non-reentrant).
|
|
*/
|
|
async selectTaskWorkflowAndReconcile(
|
|
taskId: string,
|
|
workflowId: string,
|
|
): Promise<{
|
|
enabledWorkflowSteps: string[];
|
|
reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string };
|
|
}> {
|
|
const enabledWorkflowSteps = await this.selectTaskWorkflow(taskId, workflowId);
|
|
if (!(await this.workflowColumnsFlagOn())) {
|
|
return { enabledWorkflowSteps };
|
|
}
|
|
const newIr = this.resolveTaskWorkflowIrSync(taskId);
|
|
const current = this.readTaskFromDb(taskId, { includeDeleted: false });
|
|
if (!current) return { enabledWorkflowSteps };
|
|
const fromColumn = current.column;
|
|
const decision = resolveSwitchReconciliation(newIr, fromColumn);
|
|
if (!decision.preserved && decision.targetColumn !== fromColumn) {
|
|
await this.rehomeOccupant(taskId, decision.targetColumn, "workflow-switch", { workflowId });
|
|
}
|
|
return {
|
|
enabledWorkflowSteps,
|
|
reconciliation: {
|
|
preserved: decision.preserved,
|
|
fromColumn,
|
|
toColumn: decision.targetColumn,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Clear a task's workflow selection and its enabled steps. */
|
|
async clearTaskWorkflowSelection(taskId: string): Promise<void> {
|
|
await this.withTaskLock(taskId, async () => {
|
|
this.removeMaterializedSelection(taskId);
|
|
await this.updateTaskUnlocked(taskId, { enabledWorkflowSteps: [] });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Delete append-only operational-log rows older than `retentionMs`. Returns
|
|
* zeroed counts when retention is disabled (`<= 0`). This is the primary lever
|
|
* against unbounded database growth — see `Database.pruneOperationalLogs`.
|
|
*/
|
|
pruneOperationalLogs(retentionMs: number): { deletedByTable: Record<string, number>; deletedTotal: number } {
|
|
return this.db.pruneOperationalLogs(retentionMs);
|
|
}
|
|
|
|
/**
|
|
* Prune per-task JSONL agent log files by removing entries older than the
|
|
* configured retention window. Only prunes files for soft-deleted or archived
|
|
* tasks (avoids removing logs for still-active tasks). Returns zeroed counts
|
|
* when retention is disabled (`<= 0`).
|
|
*/
|
|
pruneAgentLogFiles(retentionDays: number): { prunedFiles: number; prunedEntries: number; freedBytes: number } {
|
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) {
|
|
return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 };
|
|
}
|
|
// Only prune JSONL files for tasks that are no longer active (soft-deleted or archived)
|
|
const inactiveTaskIds = new Set(
|
|
(
|
|
this.db
|
|
.prepare(`SELECT id FROM tasks WHERE deletedAt IS NOT NULL OR "column" = 'archived'`)
|
|
.all() as Array<{ id: string }>
|
|
).map((row) => row.id),
|
|
);
|
|
return pruneAgentLogFileEntries(this.tasksDir, retentionDays, inactiveTaskIds);
|
|
}
|
|
|
|
getRootDir(): string {
|
|
return this.rootDir;
|
|
}
|
|
|
|
/** Return the `.fusion` directory path (e.g. `/project/.fusion`). */
|
|
getFusionDir(): string {
|
|
return this.fusionDir;
|
|
}
|
|
|
|
getTasksDir(): string {
|
|
return this.tasksDir;
|
|
}
|
|
|
|
getTaskDir(id: string): string {
|
|
return this.taskDir(id);
|
|
}
|
|
|
|
/** Expose the shared Database instance for co-located stores (e.g. AiSessionStore). */
|
|
getDatabase(): Database {
|
|
return this.db;
|
|
}
|
|
|
|
getBootstrappedAt(): number | null {
|
|
return this.db.getBootstrappedAt();
|
|
}
|
|
|
|
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;
|
|
corruptionDetected: boolean;
|
|
corruptionErrors: string[];
|
|
lastCheckedAt: Date | null;
|
|
isRunning: boolean;
|
|
} {
|
|
const corruptionDetected = this.db.corruptionDetected;
|
|
return {
|
|
healthy: !corruptionDetected,
|
|
corruptionDetected,
|
|
corruptionErrors: this.db.integrityCheckErrors.slice(0, 5),
|
|
lastCheckedAt: this.db.integrityCheckLastRunAt ? new Date(this.db.integrityCheckLastRunAt) : null,
|
|
isRunning: this.db.integrityCheckPending,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Force-run an integrity check synchronously and return the refreshed health
|
|
* snapshot. Used by `POST /api/health/refresh` so users can clear a stale
|
|
* corruption banner after they've repaired the database in place
|
|
* (e.g. via `REINDEX` or `fn db --vacuum`) without having to restart the
|
|
* engine to re-arm the once-at-boot background check.
|
|
*/
|
|
refreshDatabaseHealth(): ReturnType<TaskStore["getDatabaseHealth"]> {
|
|
this.db.refreshIntegrityCheck();
|
|
return this.getDatabaseHealth();
|
|
}
|
|
|
|
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 GoalStore instance for project-scoped goals operations.
|
|
* Lazily initializes the GoalStore on first access.
|
|
*/
|
|
getGoalStore(): GoalStore {
|
|
if (!this.goalStore) {
|
|
this.goalStore = new GoalStore(this.fusionDir, this.db);
|
|
}
|
|
return this.goalStore;
|
|
}
|
|
|
|
/**
|
|
* 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) ────────────────────────
|
|
|
|
}
|