feat(FN-4429): complete Step 1 — plumb moveSource and userPaused

Fusion-Task-Id: FN-4429
Fusion-Task-Lineage: b010437b-0e58-43f6-81b3-40dcebbf0873
This commit is contained in:
Fusion
2026-05-14 09:25:49 -07:00
committed by gsxdsm
parent b14a89d948
commit a0f2bc530a
5 changed files with 65 additions and 15 deletions

View File

@@ -362,15 +362,19 @@ describe("TaskStore", () => {
expect(events[0].description).toBe("event test");
});
it("moveTask emits task:moved with from/to columns", async () => {
it("moveTask emits task:moved with from/to columns and source", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:moved", (data: any) => events.push(data));
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(events).toHaveLength(1);
expect(events[0].from).toBe("triage");
expect(events[0].to).toBe("todo");
expect(events[0].source).toBe("user");
expect(events[0].task.id).toBe(task.id);
await store.moveTask(task.id, "in-progress");
expect(events.at(-1)?.source).toBe("engine");
});
it("updateTask emits task:updated with the updated task", async () => {
@@ -421,6 +425,32 @@ describe("TaskStore", () => {
});
describe("userPaused move semantics", () => {
it("sets userPaused for user move to todo and clears on in-progress", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "todo", { moveSource: "user" });
const parked = await store.getTask(task.id);
expect(parked.userPaused).toBe(true);
await store.moveTask(task.id, "in-progress");
const resumed = await store.getTask(task.id);
expect(resumed.userPaused).toBeUndefined();
});
it("does not set userPaused for engine preserve-resume move", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "todo", { preserveResumeState: true });
const bounced = await store.getTask(task.id);
expect(bounced.userPaused).toBeUndefined();
});
});
describe("execution timing timestamps", () => {
it("preserves the original executionStartedAt across an internal rerun bounce", async () => {
const task = await store.createTask({ description: "retry bounce timing" });

View File

@@ -195,6 +195,7 @@ CREATE TABLE IF NOT EXISTS tasks (
worktree TEXT,
blockedBy TEXT,
paused INTEGER DEFAULT 0,
userPaused INTEGER DEFAULT 0,
baseBranch TEXT,
branch TEXT,
executionStartBranch TEXT,
@@ -1611,6 +1612,7 @@ export class Database {
if (this.hasTable("tasks")) {
this.addColumnIfMissing("tasks", "executionStartBranch", "TEXT");
this.addColumnIfMissing("tasks", "review", "TEXT");
this.addColumnIfMissing("tasks", "userPaused", "INTEGER DEFAULT 0");
}
if (version >= SCHEMA_VERSION) return;

View File

@@ -60,6 +60,7 @@ interface TaskRow {
worktree: string | null;
blockedBy: string | null;
paused: number | null;
userPaused: number | null;
baseBranch: string | null;
executionStartBranch: string | null;
branch: string | null;
@@ -492,7 +493,7 @@ function deepMergeWithNullDelete(
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:moved": [data: { task: Task; from: Column; to: Column; source: "user" | "engine" }];
"task:updated": [task: Task];
"task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }];
"task:merged": [result: MergeResult];
@@ -984,6 +985,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
worktree: row.worktree || undefined,
blockedBy: row.blockedBy || undefined,
paused: row.paused ? true : undefined,
userPaused: row.userPaused ? true : undefined,
baseBranch: row.baseBranch || undefined,
executionStartBranch: row.executionStartBranch || undefined,
branch: row.branch || undefined,
@@ -1341,7 +1343,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prefix = tableAlias ? `${tableAlias}.` : "";
return [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"worktree", "blockedBy", "paused", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1390,7 +1392,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
const columns = [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"worktree", "blockedBy", "paused", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1443,6 +1445,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.worktree ?? null,
task.blockedBy ?? null,
task.paused ? 1 : 0,
task.userPaused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.executionStartBranch ?? null,
@@ -1541,7 +1544,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -1566,7 +1569,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -1590,6 +1593,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
worktree = excluded.worktree,
blockedBy = excluded.blockedBy,
paused = excluded.paused,
userPaused = excluded.userPaused,
baseBranch = excluded.baseBranch,
branch = excluded.branch,
executionStartBranch = excluded.executionStartBranch,
@@ -3707,6 +3711,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* scheduler) so the store stays free of worktree-naming policy.
*/
allocateWorktree?: (reservedNames: Set<string>) => string | null;
/** Distinguishes user-initiated moves from engine-internal transitions. */
moveSource?: "user" | "engine";
},
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3740,6 +3746,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
);
}
const moveSource = options?.moveSource ?? "engine";
const fromColumn = task.column;
if (fromColumn === "in-review" && toColumn === "done") {
const mergeBlocker = getTaskMergeBlocker(task);
@@ -3757,6 +3764,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (toColumn === "in-progress" && !task.executionStartedAt) {
task.executionStartedAt = task.columnMovedAt;
}
if (toColumn === "in-progress") {
task.userPaused = undefined;
}
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
@@ -3784,6 +3794,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.blockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
if (moveSource === "user" && toColumn === "todo") {
task.userPaused = true;
} else {
task.userPaused = undefined;
}
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
@@ -3880,7 +3895,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource });
return task;
});
}
@@ -4406,7 +4421,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
if (movedToTriage) {
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column });
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
}
this.emit("task:updated", task);
return task;
@@ -5402,7 +5417,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this.atomicWriteTaskJson(dir, task);
this.clearLinkedAgentTaskIds(id, task.updatedAt);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" });
return task;
}
@@ -5428,7 +5443,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.taskCache.delete(id);
}
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" });
return this.archiveEntryToTask(entry, false);
});
}
@@ -5508,7 +5523,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column, source: "engine" });
return task;
});
}
@@ -5537,7 +5552,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(task.id, { ...task });
this.emit("task:moved", { task, from: fromColumn, to: "done" as Column });
this.emit("task:moved", { task, from: fromColumn, to: "done" as Column, source: "engine" });
}
private clearDoneTransientFields(task: Task): boolean {
@@ -5658,7 +5673,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Task moved to archive — emit task:moved (matching what
// archiveTask emits in-process) so the activity-log listener
// records it correctly.
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column });
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" });
} else {
this.emit("task:deleted", cached);
}
@@ -5686,7 +5701,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (cached.column !== task.column) {
const from = cached.column;
this.taskCache.set(task.id, { ...task });
this.emit("task:moved", { task, from, to: task.column });
this.emit("task:moved", { task, from, to: task.column, source: "engine" });
} else {
this.taskCache.set(task.id, { ...task });
this.emit("task:updated", task);

View File

@@ -1222,6 +1222,8 @@ export interface Task {
blockedBy?: string;
/** When true, all automated agent and scheduler interaction is suspended. */
paused?: boolean;
/** When true, this task was explicitly moved back to todo by a user and should not auto-dispatch. */
userPaused?: boolean;
/** Optional machine-readable reason for automated pauses (for example dispatch-storm). */
pausedReason?: string;
/** ISO timestamp set when the task first crossed the soft token budget cap. */