chore(FN-4350): import dependency content from fusion/fn-4326

Squash-imported the working tree of fusion/fn-4326 as a single commit so this branch carries the dep's content without inheriting its individual commits. If the dep is later squash-merged to main, this commit's patch-id should match the merge and rebase cleanly.

Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
This commit is contained in:
gsxdsm
2026-05-13 11:57:14 -07:00
parent bb0d6118e2
commit f378874c14
11 changed files with 423 additions and 24 deletions

View File

@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore moveTask preserveStatus", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
it("clears status/error by default when moving in-progress to todo", async () => {
const task = await harness.store().createTask({ description: "preserveStatus default clear" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().updateTask(task.id, {
status: "failed",
error: "boom",
});
const moved = await harness.store().moveTask(task.id, "todo");
expect(moved.status).toBeUndefined();
expect(moved.error).toBeUndefined();
});
it("preserves status/error when preserveStatus is true on in-progress to todo", async () => {
const task = await harness.store().createTask({ description: "preserveStatus true in-progress" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().updateTask(task.id, {
status: "failed",
error: "branch conflict",
});
const moved = await harness.store().moveTask(task.id, "todo", { preserveStatus: true });
expect(moved.status).toBe("failed");
expect(moved.error).toBe("branch conflict");
});
it("preserves status/error on in-review to todo when preserveStatus is true", async () => {
const task = await harness.store().createTask({ description: "preserveStatus true in-review" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().moveTask(task.id, "in-review");
await harness.store().updateTask(task.id, {
status: "failed",
error: "recovery exhausted",
});
const moved = await harness.store().moveTask(task.id, "todo", { preserveStatus: true });
expect(moved.status).toBe("failed");
expect(moved.error).toBe("recovery exhausted");
});
});

View File

@@ -3461,6 +3461,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* clear the worktree.
*/
preserveWorktree?: boolean;
/**
* When true, do not clear task.status/task.error/task.pausedReason on
* reopen-to-todo/triage transitions. Required so recovery handlers that
* bounce through todo can keep sticky failed state context.
*/
preserveStatus?: boolean;
/**
* When transitioning to in-progress on a task that has no worktree
* assigned, invoke this allocator to pick a path. The store calls
@@ -3541,8 +3547,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
&& (toColumn === "todo" || toColumn === "triage");
if (isReopenToTodoOrTriage) {
task.status = undefined;
task.error = undefined;
if (!options?.preserveStatus) {
task.status = undefined;
task.error = undefined;
task.pausedReason = undefined;
}
task.blockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
@@ -3675,7 +3684,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3803,6 +3812,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} 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.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) {

View File

@@ -1162,6 +1162,12 @@ export interface Task {
blockedBy?: string;
/** When true, all automated agent and scheduler interaction is suspended. */
paused?: boolean;
/** Optional machine-readable reason for automated pauses (for example dispatch-storm). */
pausedReason?: string;
/** Dispatch-storm cycle counter tracked by scheduler for todo↔in-progress loop detection. */
dispatchStormCount?: number;
/** ISO timestamp of the most recent dispatch-storm cycle increment. */
lastDispatchAt?: string;
/** When set, this task was paused because the agent with this ID was paused. Cleared when the agent resumes. Distinct from user-initiated pause. */
pausedByAgentId?: string;
/** Configured merge target/base branch for this task (task intent).