Files
fusion/packages/core/src/task-store/task-update.ts
gsxdsm ebe514c3e4 FN-8677: propagate task update lanes before conversion
Propagate cache-warmed workflow lanes through task updates so synchronous engine consumers support renamed boards.

- Add task lane cache and attach resolved lanes to task:updated metadata.
- Update scheduler, triage, and notification consumers to use carried lanes with bridge-safe fallbacks.
- Cover lane propagation and renamed-lane event behavior with core and engine tests.

Files changed:
 .changeset/fn-8677-manual-merge-hold-lanes.md      |   7 ++
 .changeset/task-updated-carries-lanes.md           |   7 ++
 ...orkflow-ir-readers-always-return-the-default.md |  22 +++++
 .../sync-workflow-ir-second-blocker.test.ts        |  43 +++-----
 .../core/src/__tests__/task-lane-cache.test.ts     |  30 ++++++
 .../task-updated-lanes-emit-surfaces.test.ts       |  92 ++++++++++++++++++
 .../__tests__/task-updated-lanes-payload.test.ts   |  42 ++++++++
 packages/core/src/index.ts                         |   1 +
 packages/core/src/store.ts                         |  36 ++++++-
 packages/core/src/task-lane-cache.ts               |  63 ++++++++++++
 .../core/src/task-store/archive-lifecycle-2.ts     |   3 +
 packages/core/src/task-store/moves.ts              |   1 +
 packages/core/src/task-store/task-artifacts-ops.ts |   1 +
 packages/core/src/task-store/task-update.ts        |   1 +
 packages/core/src/task-store/update-task-deps.ts   |   4 +-
 .../core/src/task-store/workflow-definitions.ts    |  71 +++++---------
 .../__tests__/scheduler-task-updated-lanes.test.ts | 108 +++++++++++++++++++++
 .../task-updated-lanes-bridge-compat.test.ts       |  94 ++++++++++++++++++
 ...task-updated-lanes-engine-emit-surfaces.test.ts | 101 +++++++++++++++++++
 .../src/__tests__/triage-pause-abort.test.ts       |  22 +++++
 .../src/__tests__/triage-planning-wake.test.ts     |  25 +++++
 .../notification-renamed-lifecycle-columns.test.ts |  84 +++++++++++++++-
 .../__tests__/task-wedge-notification.test.ts      |  19 ++++
 .../src/notification/notification-service.ts       |  56 ++++-------
 packages/engine/src/scheduler.ts                   |  62 +++---------
 packages/engine/src/triage.ts                      | 105 ++++++--------------
 scripts/lib/inert-sync-lane-baseline.json          |   5 +-
 27 files changed, 858 insertions(+), 247 deletions(-)

Fusion-Task-Id: FN-8677

Fusion-Task-Lineage: d8fef9db-0f88-4dfd-9813-be25e10e3588

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 01:15:10 -07:00

1022 lines
51 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 {toTaskMoveLanes} from "../workflow-lifecycle-traits.js";
import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.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, resolveNodeOverrideLanes} from "../node-override-guard.js";
import {isTaskTerminalNodeIdAsync} from "../workflow-ir-resolver.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";
import {assertValidProviderInstanceId} from "../provider-instance.js";
export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext,): Promise<Task> {
/* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */
for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) {
const value = (updates as Record<string, unknown>)[key];
if (value !== undefined && value !== null) assertValidProviderInstanceId(value);
}
{
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) {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:40 (#2821 review — greptile, second call site):
THE COLUMN IS RE-READ AFTER THE AWAIT.
`task` was loaded above, and awaiting lane resolution here opened a window: another process
moving the card into a resolved WIP lane during that await left the guard judging a STALE
non-WIP column, so the mid-flight refusal passed for a task that had started running.
I fixed exactly this at the sibling call site in `branch-and-pr-entities.ts` by resolving lanes
BEFORE the task read, and missed it here — the same half-conversion this program keeps
finding, in my own fix. Hoisting is not available at this site because `task` is the working
copy the whole function mutates, so the column is re-read instead and only for the guard.
The re-read is best-effort: if it fails, the already-loaded copy is used, which is strictly no
worse than before this change.
*/
const overrideLanes = await resolveNodeOverrideLanes(store, id);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:50 (#2821 review — greptile, and it caught a DEADLOCK I shipped):
RE-READ WITHOUT THE LOCK. My previous version used `store.getTask(id)`, which acquires the
per-task lock. This function is `updateTaskUnlockedImpl` — the caller ALREADY HOLDS that lock,
and it is non-reentrant, so the inner read waited on the outer update forever. A stale-column
race is a narrow window; a deadlock is every `nodeId` update.
`readTaskJson` is the lock-free read this function already uses for its own working copy, so
the column is refreshed after the await without touching the lock. Falls back to the copy
loaded above if the re-read fails, which is no worse than before.
*/
const freshForGuard = await store.readTaskJson(dir).catch(() => null);
/*
FNXC:StateMachine 2026-07-31-10:20 (PR #2793's finding — the INNER half, merged with #2821):
THIS GUARD RUNS SECOND AND USED TO OVERRIDE THE FIRST. `updateTaskImpl` resolves the terminal
question and passes it in; this call passed no `isTerminalNodeId`, so it fell to
`defaultIsTerminalNodeId` — the bare literal `nodeId === "end"`. An unconverted literal behind
a converted call site, which meant converting the outer guard alone changed nothing an
operator could see. PR #2793 measured exactly that: correcting either guard on its own left
the rejected-`end` case unmoved, because both independently called it terminal.
Resolved here too, from the task's own workflow, and threaded ALONGSIDE #2821's
`overrideLanes` rather than in place of them — the two answer different questions about the
same call, and dropping either re-opens a defect the other did not cover.
*/
const terminal = updates.nodeId == null
? false
: await isTaskTerminalNodeIdAsync(store, id, updates.nodeId);
const validation = validateNodeOverrideChange(freshForGuard ?? task, updates.nodeId ?? null, {
...overrideLanes,
isTerminalNodeId: () => terminal,
});
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;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-02:40 (batch-core feed):
THIS WROTE A COLUMN THAT NO LONGER EXISTS ON ANY BOARD.
Adding a new dependency to a hold-lane card re-seeds it for re-specification. The destination
was the literal `"triage"` — a column U11 (#2515) DELETED. The default lineage is now
`todo | in-progress | in-review | done | archived`, so on a stock board today this moves the
card into a column nothing declares and nothing renders: the card leaves its lane and appears
in none, recoverable only by moving it back by hand.
That is a live defect independent of renaming, which is why it is fixed rather than flagged:
the old behaviour cannot be preserved, because the column it targeted is gone.
Resolved intake lane, and NO literal fallback. On the default board the intake lane is `todo`
— the card's current column — so the move becomes a no-op while the status reset and the log
entry still record the re-specification. When the workflow will not resolve, the column is
left ALONE: refusing to move is recoverable, writing a column that may not exist is not.
*/
const depLanes = hasNewDeps
? await resolveTaskLifecycleColumns(store, id).catch(() => undefined)
: undefined;
/* DELIBERATE-LITERAL — the unresolvable-workflow default for the SOURCE lane only; the
destination below never falls back to a literal. Reviewed 2026-07-31-02:40. */
const holdLane = depLanes === undefined ? "todo" : depLanes.hold;
if (hasNewDeps && holdLane !== undefined && task.column === holdLane) {
const intakeLane = depLanes?.intake;
const relocating = intakeLane !== undefined && intakeLane !== task.column;
if (relocating) {
task.column = intakeLane;
task.columnMovedAt = new Date().toISOString();
}
task.status = undefined;
const depLogEntry: TaskLogEntry = {
timestamp: new Date().toISOString(),
action: relocating
? `Moved to ${intakeLane} for re-specification — new dependency added`
: "Re-seeded 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;
}
/*
FNXC:PlanApproval 2026-08-01-04:39:
`awaitingApprovalReason` was persisted (persistence.ts) and serialized (serialization.ts)
but never applied by this field-by-field merge, so EVERY writer silently lost it — the
executor's Plan Review replan-cap park (`plan-review-replan-cap`) and the triage manual
gate's explicit null-clear both no-oped, and FN-8647's non-converging Plan Review loop
surfaced on the board as a generic "needs approval" with no explanation. Merge it like the
other nullable fields (null clears), and auto-clear the stored reason whenever a status
write moves the task OFF `awaiting-approval` without the caller addressing the reason, so
an approved/replanned card can never carry a stale escalation reason into its next park.
*/
const reasonUpdate = (updates as Record<string, unknown>).awaitingApprovalReason;
if (reasonUpdate === null) {
task.awaitingApprovalReason = undefined;
} else if (reasonUpdate !== undefined) {
task.awaitingApprovalReason = reasonUpdate as Task["awaitingApprovalReason"];
} else if (updates.status !== undefined && updates.status !== "awaiting-approval") {
task.awaitingApprovalReason = undefined;
}
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;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-02:40 (batch-core feed):
Clearing the `paused` STATUS on unassignment applies to the two lanes where a card is
actively being worked — wip and review. Keyed on the literals, a renamed board left
`status: "paused"` behind after the pause itself was lifted, so the card read as paused in
every status-driven surface while `task.paused` was already false. A card that is not paused
but says it is, forever.
The lanes are compared directly rather than routed through `column-roles.ts`: those helpers
take a column's TRAIT FLAGS, and this site holds a resolved lane struct, not flags.
Manufacturing flags from lane equality just to call the helper would be a longer way to write
the same comparison while looking like it consulted the trait registry.
*/
const unassignLanes = await resolveTaskLifecycleColumns(store, id).catch(() => undefined);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-31-02:40. */
const inActiveWorkLane = unassignLanes === undefined
? task.column === "in-progress" || task.column === "in-review"
: task.column === unassignLanes.wip || task.column === unassignLanes.review;
if (inActiveWorkLane) {
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.wedgeNotification === null) {
task.wedgeNotification = undefined;
} else if (updates.wedgeNotification !== undefined) {
task.wedgeNotification = updates.wedgeNotification;
}
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.credentialInstanceId === null) {
task.credentialInstanceId = undefined;
} else if (updates.credentialInstanceId !== undefined) {
task.credentialInstanceId = updates.credentialInstanceId;
}
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.validatorCredentialInstanceId === null) {
task.validatorCredentialInstanceId = undefined;
} else if (updates.validatorCredentialInstanceId !== undefined) {
task.validatorCredentialInstanceId = updates.validatorCredentialInstanceId;
}
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.planningCredentialInstanceId === null) {
task.planningCredentialInstanceId = undefined;
} else if (updates.planningCredentialInstanceId !== undefined) {
task.planningCredentialInstanceId = updates.planningCredentialInstanceId;
}
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.mergerCredentialInstanceId === null) task.mergerCredentialInstanceId = undefined;
else if (updates.mergerCredentialInstanceId !== undefined) task.mergerCredentialInstanceId = updates.mergerCredentialInstanceId;
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.cumulativePlanningMs === null) {
task.cumulativePlanningMs = undefined;
} else if (updates.cumulativePlanningMs !== undefined) {
task.cumulativePlanningMs = updates.cumulativePlanningMs;
}
if (updates.planningStartedAt === null) {
task.planningStartedAt = undefined;
} else if (updates.planningStartedAt !== undefined) {
task.planningStartedAt = updates.planningStartedAt;
}
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-20-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-07-20-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) {
/* FNXC:WorkflowEvents 2026-07-31-23:10 (fleet — the last two emitters):
#3109 attached lanes at moves.ts and #3120 at the archive/completion emits. This one and
`update-task-deps.ts` were still sending `lanes: undefined`, and a listener reads absence as
"unknown" and falls back to `resolveTaskParkedColumnsSync` — the DEFAULT board under
PostgreSQL. So these two paths kept the pre-#3109 behaviour while the listeners read as
resolved. */
const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, id).catch(() => undefined));
store.laneCache.set(task.id, lanes);
store.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine", lanes });
}
store.emitTaskLifecycleEventSafely("task:updated", [task]);
return task;
}
}