Files
fusion/packages/core/src/task-store/task-update.ts
gsxdsm 1f77935358 FN-8307: guard heartbeat task creation with mission lineage
Require autonomous heartbeat work to prove and retain approved mission lineage.

- Validate and persist mission lineage for created and delegated tasks
- Block off-mission heartbeat actions and reconcile roadmap failures without completion
- Renew and release mission symbol locks across workflow lifecycle transitions

Files changed:
 .changeset/fn-8307-heartbeat-mission-guard.md      |   7 ++
 docs/missions.md                                   |   4 +
 packages/core/src/__tests__/symbol-locks.test.ts   |  12 +++
 packages/core/src/task-store/task-update.ts        |  17 +++-
 .../engine/src/__tests__/agent-action-gate.test.ts |   9 +-
 .../src/__tests__/agent-tools-delegation.test.ts   |  41 +++++++-
 .../src/__tests__/gating-classifications.test.ts   |   7 +-
 .../src/__tests__/mission-feature-sync.test.ts     |  10 +-
 .../src/__tests__/mission-symbol-admission.test.ts |  25 +++++
 .../src/__tests__/permanent-agent-gating.test.ts   |   3 +-
 packages/engine/src/agent-action-gate.ts           |  28 +++++-
 packages/engine/src/agent-heartbeat-prompts.ts     |  14 +--
 packages/engine/src/agent-heartbeat.ts             |  34 +++++--
 packages/engine/src/agent-tools.ts                 | 103 +++++++++++++++++++--
 packages/engine/src/executor.ts                    |   2 +-
 packages/engine/src/gating-classifications.ts      |  11 ++-
 packages/engine/src/mission-feature-sync.ts        |  39 +++-----
 packages/engine/src/mission-symbol-admission.ts    |  35 ++++++-
 packages/engine/src/permanent-agent-gating.ts      |  22 +++++
 packages/engine/src/scheduler.ts                   |  21 +++--
 packages/engine/src/step-session-executor.ts       |   2 +-
 packages/engine/src/triage.ts                      |   2 +-
 22 files changed, 369 insertions(+), 79 deletions(-)

Fusion-Task-Id: FN-8307

Fusion-Task-Lineage: 4ca2ccac-db6e-4e5c-9cc6-d62a40d4f30a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-19 22:43:13 -07:00

856 lines
40 KiB
TypeScript

/**
* task-update operations.
*
* FNXC:StoreModularization 2026-06-25-00:00:
* Extracted from the monolithic packages/core/src/store.ts as a pure
* behavior-preserving refactor. Each function receives the TaskStore
* instance as its first parameter and performs byte-identical work.
*/
import {type TaskStore, storeLog} from "../store.js";
import {InvalidFileScopeError} from "./errors.js";
import {mkdir, readFile, writeFile} from "node:fs/promises";
import {join} from "node:path";
import {existsSync} from "node:fs";
import type {Task, Column, TaskLogEntry, RunMutationContext} from "../types.js";
import {validateCustomFieldPatch, CustomFieldRejectionError} from "../task-fields.js";
import "../builtin-traits.js";
import {normalizeTaskPriority} from "../task-priority.js";
import {validateNodeOverrideChange} from "../node-override-guard.js";
import {extractTaskIdTokens, normalizeTitleForTaskId} from "../task-title-id-drift.js";
import {buildBootstrapPrompt} from "../mesh-task-replication.js";
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHeadingLine, rewriteMissionSection} from "../task-store/comments.js";
import {applyOriginalDescription} from "../original-description-policy.js";
import {normalizeTaskReviewState} from "../task-store/review-state.js";
import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt, resolveTaskSymbolsForTask} from "../task-symbol-resolution.js";
export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext,): Promise<Task> {
{
if (updates.dependencies !== undefined) {
await store.assertNoDependencyCycle(
id,
updates.dependencies,
"updateTask",
new Map([[id, updates.dependencies]]),
);
}
const dir = store.taskDir(id);
const task = await store.readTaskJson(dir);
const wasFailed = task.status === "failed";
// 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;
}
if (updates.workspaceWorktrees !== undefined) {
task.workspaceWorktrees = updates.workspaceWorktrees;
}
// 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;
// U11/KTD-13: customFields writes are validated against the task's workflow
// field schema through the single authority (task-fields.ts). The patch is
// merged into the existing values (delete-on-null), mirroring
// updateTaskCustomFields. Backward-compat note: U4 round-tripped the object
// opaquely; the field system now enforces type/enum/unknown-id rules, so a
// write against a workflow with no fields (the default) is rejected with a
// typed CustomFieldRejectionError rather than silently persisted.
if (updates.customFields !== undefined) {
const defs = store.resolveTaskCustomFieldDefsSync(id);
const result = validateCustomFieldPatch(defs, updates.customFields);
if (!result.ok) throw new CustomFieldRejectionError(result.rejection);
task.customFields = store.mergeCustomFieldPatch(task.customFields, result.normalized);
}
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) {
await store.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;
}
// Explicit task-level auto-merge overrides written through updateTask are
// user provenance. Task creation mirrors this for create-time overrides.
if (updates.autoMerge === null) {
task.autoMerge = undefined;
task.autoMergeProvenance = undefined;
} else if (updates.autoMerge !== undefined) {
task.autoMerge = updates.autoMerge;
task.autoMergeProvenance = "user";
}
if (updates.branch === null) {
task.branch = undefined;
} else if (updates.branch !== undefined) {
task.branch = updates.branch;
}
// Keep in sync with the first autoMerge block above; both legacy update
// paths may run before persistence.
if (updates.autoMerge === null) {
task.autoMerge = undefined;
task.autoMergeProvenance = undefined;
} else if (updates.autoMerge !== undefined) {
task.autoMerge = updates.autoMerge;
task.autoMergeProvenance = "user";
}
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.graphResumeRetryCount === null) {
task.graphResumeRetryCount = null;
} else if (updates.graphResumeRetryCount !== undefined) {
task.graphResumeRetryCount = updates.graphResumeRetryCount;
}
if (updates.consecutiveToolFailureRetryCount === null) task.consecutiveToolFailureRetryCount = null;
else if (updates.consecutiveToolFailureRetryCount !== undefined) task.consecutiveToolFailureRetryCount = updates.consecutiveToolFailureRetryCount;
if (updates.executorEscalationAttempted === null) task.executorEscalationAttempted = null;
else if (updates.executorEscalationAttempted !== undefined) task.executorEscalationAttempted = updates.executorEscalationAttempted;
if (updates.toolFailureDetectorLogCursor === null) task.toolFailureDetectorLogCursor = null;
else if (updates.toolFailureDetectorLogCursor !== undefined) task.toolFailureDetectorLogCursor = updates.toolFailureDetectorLogCursor;
if (updates.toolFailureRetryExhaustedAuditEmitted === null) task.toolFailureRetryExhaustedAuditEmitted = null;
else if (updates.toolFailureRetryExhaustedAuditEmitted !== undefined) task.toolFailureRetryExhaustedAuditEmitted = updates.toolFailureRetryExhaustedAuditEmitted;
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.executeRequeueLoopCount === null) {
task.executeRequeueLoopCount = undefined;
} else if (updates.executeRequeueLoopCount !== undefined) {
task.executeRequeueLoopCount = updates.executeRequeueLoopCount;
}
if (updates.executeRequeueLoopSignature === null) {
task.executeRequeueLoopSignature = undefined;
} else if (updates.executeRequeueLoopSignature !== undefined) {
task.executeRequeueLoopSignature = updates.executeRequeueLoopSignature;
}
if (updates.postReviewFixCount === null) {
task.postReviewFixCount = undefined;
} else if (updates.postReviewFixCount !== undefined) {
task.postReviewFixCount = updates.postReviewFixCount;
}
if (updates.planReviewReplanCount === null) {
task.planReviewReplanCount = undefined;
} else if (updates.planReviewReplanCount !== undefined) {
task.planReviewReplanCount = updates.planReviewReplanCount;
}
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;
}
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker; null clears the taint.
if (updates.bulkCompletionRefusalAt === null) {
task.bulkCompletionRefusalAt = undefined;
} else if (updates.bulkCompletionRefusalAt !== undefined) {
task.bulkCompletionRefusalAt = updates.bulkCompletionRefusalAt;
}
/*
FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3):
Node entry SETS the pin; node settle CLEARS it (null). Both directions must be writable
through updateTask or the pin either never lands or outlives its node and reports drift
against a node the task already left.
*/
if (updates.workflowIrPin === null) {
task.workflowIrPin = undefined;
} else if (updates.workflowIrPin !== undefined) {
task.workflowIrPin = updates.workflowIrPin;
}
if (updates.workflowIrPinNodeId === null) {
task.workflowIrPinNodeId = undefined;
} else if (updates.workflowIrPinNodeId !== undefined) {
task.workflowIrPinNodeId = updates.workflowIrPinNodeId;
}
if (updates.workflowIrPinColumnId === null) {
task.workflowIrPinColumnId = undefined;
} else if (updates.workflowIrPinColumnId !== undefined) {
task.workflowIrPinColumnId = updates.workflowIrPinColumnId;
}
// FNXC:LegacyAdoption 2026-07-19-03:10 (U9b / KTD-8): stamped once by adoption; null is
// reserved for tests/operator repair that need to force re-adoption.
if (updates.legacyAdoptedAt === null) {
task.legacyAdoptedAt = undefined;
} else if (updates.legacyAdoptedAt !== undefined) {
task.legacyAdoptedAt = updates.legacyAdoptedAt;
}
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) {
// Pass the task's own workflow optional-group ids through untouched so a
// toggled built-in group id (e.g. "browser-verification") is not remapped
// to a materialized step row the executor never matches (code-review P1).
const taskWorkflowId = (await store.getTaskWorkflowSelectionAsync(task.id))?.workflowId;
task.enabledWorkflowSteps = await store.resolveEnabledWorkflowSteps(
updates.enabledWorkflowSteps,
await store.optionalGroupIdSet(taskWorkflowId),
);
}
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.mergerModelProvider === null) task.mergerModelProvider = undefined;
else if (updates.mergerModelProvider !== undefined) task.mergerModelProvider = updates.mergerModelProvider;
if (updates.mergerModelId === null) task.mergerModelId = undefined;
else if (updates.mergerModelId !== undefined) task.mergerModelId = updates.mergerModelId;
if (updates.validatorThinkingLevel === null) {
task.validatorThinkingLevel = undefined;
} else if (updates.validatorThinkingLevel !== undefined) {
task.validatorThinkingLevel = updates.validatorThinkingLevel as import("../types.js").ThinkingLevel;
}
if (updates.planningThinkingLevel === null) {
task.planningThinkingLevel = undefined;
} else if (updates.planningThinkingLevel !== undefined) {
task.planningThinkingLevel = updates.planningThinkingLevel as import("../types.js").ThinkingLevel;
}
if (updates.mergerThinkingLevel === null) task.mergerThinkingLevel = undefined;
else if (updates.mergerThinkingLevel !== undefined) task.mergerThinkingLevel = updates.mergerThinkingLevel as import("../types.js").ThinkingLevel;
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;
}
/*
FNXC:PlannerOversight 2026-07-14-18:11:
sessionAdvisorEnabled: null clears to inherit project default; boolean sets override.
*/
if (updates.sessionAdvisorEnabled === null) {
task.sessionAdvisorEnabled = undefined;
} else if (updates.sessionAdvisorEnabled !== undefined) {
task.sessionAdvisorEnabled = updates.sessionAdvisorEnabled;
}
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;
}
/* FNXC:SymbolLock 2026-07-31-10:00: present undefined is an explicit clear; only absent declarations may hydrate from a prompt write. */
if (hasOwnDeclaredSymbols(updates)) {
const normalized = normalizeDeclaredSymbols(Array.isArray(updates.declaredSymbols) ? updates.declaredSymbols : []);
task.declaredSymbols = normalized.length ? normalized : undefined;
} else if (updates.prompt !== undefined) {
const normalized = normalizeDeclaredSymbols(extractDeclaredSymbolsFromPrompt(updates.prompt));
task.declaredSymbols = normalized.length ? normalized : undefined;
}
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();
// FNXC:TaskDetailPromptResilience 2026-07-10-17:00 (merge port from main):
// Perform the explicit PROMPT.md write (and its File Scope validation)
// BEFORE committing the task row, so a failed write (EACCES/EISDIR/
// disk-full) or an invalid File Scope aborts the whole update atomically.
// Previously this ran AFTER the row/task.json commit, so a failed prompt
// write returned an error while the field changes stayed committed and
// PROMPT.md went stale — a partial commit.
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);
}
// When runContext is provided, record audit event atomically with task mutation
if (runContext) {
await store.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 store.atomicWriteTaskJson(dir, task);
}
/*
FNXC:MissionSymbolAdmission 2026-08-01-00:00:
A workflow failure may park in the current in-progress column rather than
move out of it. Release that task's durable symbols on the status edge as
well as moveTask's column-exit path, allowing engine reconciliation to
record failure provenance without incorrectly completing the feature.
*/
if (store.backendMode && !wasFailed && task.status === "failed") {
const symbols = resolveTaskSymbolsForTask(task);
if (symbols.resolvable) {
await store.releaseSymbolLocks(symbols.symbols, id);
}
}
// Update cache if watcher is active
if (store.isWatching) store.taskCache.set(id, { ...task });
// 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)) {
// FNXC:TaskDetailPromptResilience 2026-07-10-15:00 (merge port from main):
// Keeping the human-visible PROMPT.md heading/mission in sync with
// task.json is cosmetic — the DB row (persisted above) is canonical. An
// unreadable/unwritable PROMPT.md (EACCES/EISDIR/transient FS error)
// must NOT fail the update itself, or every title/description edit 500s.
// Best-effort: log and skip the sync on failure.
const promptPath = join(dir, "PROMPT.md");
try {
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) {
// FNXC:OriginalDescriptionInPrompt 2026-07-14-23:35:
// Keep ## Mission and ## Original Description in sync with task.description
// on real specs so operator edits stay visible at the top of PROMPT.md.
next = rewriteMissionSection(next, task.description);
next = applyOriginalDescription(next, task.description ?? "");
}
if (next !== existingPrompt) {
await writeFile(promptPath, next);
}
}
}
} catch (err) {
storeLog.warn(`[task-detail] failed to sync PROMPT.md heading for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (movedToTriage) {
store.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
}
store.emitTaskLifecycleEventSafely("task:updated", [task]);
return task;
}
}