feat(FN-5241): add atomic in-review handoff seam with executor/self-healing

The merge introduces an atomic review handoff seam in the core store (`packages/core/src/store.ts`) and migrates executor and self-healing transitions to use it, replacing the previous multi-step mutable-state handoff with a single transactional operation. Extensive reliability backstops and regress

Fusion-Task-Id: FN-5241
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 00:54:03 -07:00
committed by gsxdsm
parent b7ddfc9d20
commit 93b11c6c0c
17 changed files with 999 additions and 264 deletions

View File

@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { HandoffInvariantViolationError, TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-handoff-to-review-test-"));
}
describe("TaskStore handoffToReview", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
vi.restoreAllMocks();
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
async function createTask(priority: "low" | "normal" | "high" | "urgent" = "normal") {
return store.createTask({ description: `handoff ${priority}`, priority });
}
async function createInProgressTask(priority: "low" | "normal" | "high" | "urgent" = "normal") {
const task = await createTask(priority);
await store.moveTask(task.id, "todo");
return store.moveTask(task.id, "in-progress");
}
function getAuditEventsByInsertion(taskId: string): Array<{
mutationType: string;
metadata: Record<string, unknown> | undefined;
}> {
const rows = store.getDatabase().prepare(`
SELECT mutationType, metadata
FROM runAuditEvents
WHERE taskId = ?
ORDER BY timestamp ASC, rowid ASC
`).all(taskId) as Array<{ mutationType: string; metadata: string | null }>;
return rows.map((row) => ({
mutationType: row.mutationType,
metadata: row.metadata ? JSON.parse(row.metadata) as Record<string, unknown> : undefined,
}));
}
it("atomically moves an in-progress task to in-review and enqueues merge work", async () => {
const task = await createInProgressTask("high");
const beforeEvents = getAuditEventsByInsertion(task.id).length;
const handedOff = await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
now: "2026-05-19T00:00:00.000Z",
});
expect(handedOff.column).toBe("in-review");
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id, priority: "high" }),
]);
const relevantEvents = getAuditEventsByInsertion(task.id)
.slice(beforeEvents)
.filter((event) =>
["task:move", "mergeQueue:enqueue", "task:handoff", "task:handoff-invariant-violation"].includes(event.mutationType)
);
expect(relevantEvents.map((event) => event.mutationType)).toEqual([
"task:move",
"mergeQueue:enqueue",
"task:handoff",
]);
expect(relevantEvents[0].metadata).toMatchObject({ from: "in-progress", to: "in-review" });
expect(relevantEvents[1].metadata).toMatchObject({ taskId: task.id, priority: "high", alreadyEnqueued: false });
expect(relevantEvents[2].metadata).toMatchObject({
taskId: task.id,
fromColumn: "in-progress",
ownerAgentId: "agent-1",
reason: "fn_task_done",
runId: "run-1",
agentId: "agent-1",
alreadyEnqueued: false,
});
});
it("is idempotent and reports alreadyEnqueued on a second handoff", async () => {
const task = await createInProgressTask();
await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
now: "2026-05-19T00:00:00.000Z",
});
const second = await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { reason: "fn_task_done", runId: "run-2", agentId: "agent-1" },
now: "2026-05-19T00:00:05.000Z",
});
expect(second.column).toBe("in-review");
expect(store.peekMergeQueue()).toHaveLength(1);
const handoffEvents = getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff");
expect(handoffEvents).toHaveLength(2);
expect(handoffEvents[1].metadata).toMatchObject({
taskId: task.id,
fromColumn: "in-review",
alreadyEnqueued: true,
runId: "run-2",
});
});
it("rolls back the column move and audit trail when enqueueMergeQueue throws", async () => {
const task = await createInProgressTask();
const beforeEvents = getAuditEventsByInsertion(task.id).length;
vi.spyOn(store, "enqueueMergeQueue").mockImplementationOnce(() => {
throw new Error("boom");
});
await expect(store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
now: "2026-05-19T00:00:00.000Z",
})).rejects.toThrow("boom");
expect((await store.getTask(task.id))?.column).toBe("in-progress");
expect(store.peekMergeQueue()).toHaveLength(0);
const newEvents = getAuditEventsByInsertion(task.id).slice(beforeEvents);
expect(newEvents.filter((event) => event.mutationType === "task:move")).toHaveLength(0);
expect(newEvents.filter((event) => event.mutationType === "task:handoff")).toHaveLength(0);
});
it("rejects archived or deleted tasks without changing queue state", async () => {
const archived = await createTask();
const deleted = await createInProgressTask();
store.getDatabase().prepare('UPDATE tasks SET "column" = ?, "deletedAt" = ? WHERE id = ?').run(
"archived",
null,
archived.id,
);
store.getDatabase().prepare('UPDATE tasks SET "deletedAt" = ? WHERE id = ?').run(
"2026-05-19T00:00:00.000Z",
deleted.id,
);
await expect(store.handoffToReview(archived.id, {
ownerAgentId: "agent-1",
evidence: { reason: "archived" },
now: "2026-05-19T00:00:01.000Z",
})).rejects.toBeInstanceOf(HandoffInvariantViolationError);
await expect(store.handoffToReview(deleted.id, {
ownerAgentId: "agent-1",
evidence: { reason: "deleted" },
now: "2026-05-19T00:00:02.000Z",
})).rejects.toBeInstanceOf(HandoffInvariantViolationError);
expect((await store.getTask(archived.id))?.column).toBe("archived");
expect(store.peekMergeQueue()).toHaveLength(0);
expect(getAuditEventsByInsertion(archived.id).filter((event) => event.mutationType === "task:handoff")).toHaveLength(0);
expect(getAuditEventsByInsertion(deleted.id).filter((event) => event.mutationType === "task:handoff")).toHaveLength(0);
});
it("audits direct moveTask in-review transitions as invariant violations", async () => {
const task = await createInProgressTask();
const moved = await store.moveTask(task.id, "in-review");
expect(moved.column).toBe("in-review");
const violations = getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff-invariant-violation");
expect(violations).toHaveLength(1);
expect(violations[0].metadata).toMatchObject({
taskId: task.id,
fromColumn: "in-progress",
callerStack: expect.any(String),
});
expect(String(violations[0].metadata?.callerStack ?? "").split("\n").length).toBeLessThanOrEqual(8);
});
it("skips invariant-violation auditing when allowDirectInReviewMove is true", async () => {
const task = await createInProgressTask();
const moved = await store.moveTask(task.id, "in-review", { allowDirectInReviewMove: true });
expect(moved.column).toBe("in-review");
expect(getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff-invariant-violation")).toHaveLength(0);
});
it("preserves failed status and error details during handoff", async () => {
const task = await createInProgressTask();
await store.updateTask(task.id, {
status: "failed",
error: "step session failed",
});
const handedOff = await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { reason: "execution-failed" },
now: "2026-05-19T00:00:00.000Z",
});
expect(handedOff.column).toBe("in-review");
expect(handedOff.status).toBe("failed");
expect(handedOff.error).toBe("step session failed");
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id }),
]);
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveWorktrunkSettings,
@@ -133,6 +133,7 @@ export {
MergeQueueTaskNotFoundError,
MergeQueueLeaseOwnershipError,
InvalidMergeQueueLeaseDurationError,
HandoffInvariantViolationError,
} from "./store.js";
export {
STOPWORDS,

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
@@ -834,6 +834,36 @@ export class InvalidMergeQueueLeaseDurationError extends Error {
}
}
export class HandoffInvariantViolationError extends Error {
constructor(
public readonly taskId: string,
public readonly fromColumn: Column,
message: string,
) {
super(message);
this.name = "HandoffInvariantViolationError";
}
}
interface MoveTaskOptions {
preserveResumeState?: boolean;
preserveProgress?: boolean;
preserveWorktree?: boolean;
preserveStatus?: boolean;
allocateWorktree?: (reservedNames: Set<string>) => string | null;
moveSource?: "user" | "engine";
skipMergeBlocker?: boolean;
allowDirectInReviewMove?: boolean;
}
interface MoveTaskInternalOptions {
fromHandoff: boolean;
runContext?: Pick<RunMutationContext, "runId" | "agentId"> | { runId?: string; agentId?: string };
ownerAgentId?: string | null;
evidence?: HandoffToReviewOptions["evidence"];
now?: string;
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
@@ -4461,264 +4491,355 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
private async readTaskForMove(id: string): Promise<Task> {
const dir = this.taskDir(id);
try {
return await this.readTaskJson(dir);
} catch (error) {
const archived = this.archiveDb.get(id);
if (!archived) {
throw error;
}
return this.archiveEntryToTask(archived, false);
}
}
async moveTask(
id: string,
toColumn: Column,
options?: {
/**
* Mark this transition as an internal bounce/pause hop rather than a
* user-initiated reset. On in-progress/done/in-review → todo/triage,
* skip the destructive cleanup that would otherwise discard resume
* state: leave step statuses intact (no resetAllStepsToPending), do
* not rewrite PROMPT.md checkboxes, and keep `worktree` +
* `executionStartedAt` so the resumed run reattaches to the same
* checkout and preserves wall-clock execution time. `status`,
* `error`, and `blockedBy` are still cleared because those are
* per-run failure state that the next run will rebuild.
*
* Used by the workflow-rerun bounce, the pause→todo paths, and
* other executor-internal requeues. NOT used by user-initiated
* "move back to todo" actions, which still want a clean slate.
*/
preserveResumeState?: boolean;
/**
* Preserve step progress (step statuses + currentStep) when reopening
* to todo/triage, while still clearing per-run execution state
* (worktree and wall-clock timing fields).
*/
preserveProgress?: boolean;
/**
* Skip the default "release worktree on requeue" behavior. Used by
* internal bounce paths (e.g. workflow-rerun) that immediately
* promote the task back to in-progress on the same checkout, where
* publishing an interim `worktree=null` state to listeners would be
* misleading. Has no effect on transitions that don't otherwise
* 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
* the allocator with a fresh `reservedNames` set (built from every
* other task's current `worktree`) inside a cross-task allocation
* lock, so two concurrent moves cannot pick the same name. The
* allocator should return an absolute path or `null` to skip
* allocation. Provided by callers (the manual-move route, the
* 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";
/** Bypass in-review merge blocker checks for explicit engine-owned transitions. */
skipMergeBlocker?: boolean;
},
options?: MoveTaskOptions,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false }));
}
async handoffToReview(taskId: string, opts: HandoffToReviewOptions): Promise<Task> {
return this.withTaskLock(taskId, async () => {
let task: Task;
try {
task = await this.readTaskJson(dir);
task = await this.readTaskForMove(taskId);
} catch (error) {
const archived = this.archiveDb.get(id);
if (!archived) {
// Public API: propagate TaskDeletedError (and other typed failures)
// instead of silently treating soft-deleted live rows as missing.
throw error;
if (error instanceof TaskDeletedError) {
const deletedTask = this.readTaskFromDb(taskId, { includeDeleted: true });
throw new HandoffInvariantViolationError(
taskId,
deletedTask?.column ?? "todo",
`Cannot hand off ${taskId} to in-review because the task is deleted`,
);
}
task = this.archiveEntryToTask(archived, false);
throw error;
}
if (task.column === toColumn) {
if (toColumn === "done" && this.clearDoneTransientFields(task)) {
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
}
return task;
}
const validTargets = VALID_TRANSITIONS[task.column];
if (!validTargets.includes(toColumn)) {
throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
`Valid targets: ${validTargets.join(", ") || "none"}`,
if (task.column === "archived" || task.deletedAt != null) {
throw new HandoffInvariantViolationError(
taskId,
task.column,
`Cannot hand off ${taskId} to in-review from ${task.column}`,
);
}
const moveSource = options?.moveSource ?? "engine";
const fromColumn = task.column;
if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) {
const mergeBlocker = getTaskMergeBlocker(task);
if (mergeBlocker) {
throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`);
}
}
task.column = toColumn;
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
return this.moveTaskInternal(
taskId,
"in-review",
{
...opts.moveOptions,
skipMergeBlocker: true,
},
{
fromHandoff: true,
runContext: {
runId: opts.evidence.runId,
agentId: opts.evidence.agentId,
},
ownerAgentId: opts.ownerAgentId,
evidence: opts.evidence,
now: opts.now,
},
task,
);
});
}
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt);
const segmentEndMs = Date.parse(task.columnMovedAt);
const segmentDeltaMs =
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
private async moveTaskInternal(
id: string,
toColumn: Column,
options: MoveTaskOptions | undefined,
internal: MoveTaskInternalOptions,
currentTask?: Task,
): Promise<Task> {
const dir = this.taskDir(id);
const task = currentTask ?? await this.readTaskForMove(id);
const moveSource = options?.moveSource ?? "engine";
if (task.column === toColumn) {
if (internal.fromHandoff && toColumn === "in-review") {
this.db.transactionImmediate(() => {
const liveRow = this.readTaskFromDb(id, { includeDeleted: true });
if (liveRow?.deletedAt) {
throw new HandoffInvariantViolationError(
id,
task.column,
`Cannot hand off ${id} to in-review because the task is deleted`,
);
}
const existing = this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id) as { 1: number } | undefined;
this.insertRunAuditEventRow({
taskId: id,
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
domain: "database",
mutationType: "task:move",
target: id,
metadata: {
from: task.column,
to: toColumn,
moveSource,
},
});
this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now });
this.insertRunAuditEventRow({
taskId: id,
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
domain: "database",
mutationType: "task:handoff",
target: id,
metadata: {
taskId: id,
fromColumn: task.column,
ownerAgentId: internal.ownerAgentId ?? null,
reason: internal.evidence?.reason,
runId: internal.runContext?.runId,
agentId: internal.runContext?.agentId,
alreadyEnqueued: Boolean(existing),
},
});
});
return task;
}
// Wall-clock end-to-end runtime: set on first transition into in-progress
// and first transition into done. Never overwritten — see retry-clear
// logic below for the path that resets these for a fresh run.
if (toColumn === "in-progress") {
task.cumulativeActiveMs ??= 0;
if (!task.firstExecutionAt) {
task.firstExecutionAt = task.columnMovedAt;
}
if (!task.executionStartedAt) {
task.executionStartedAt = task.columnMovedAt;
}
if (toColumn === "done" && this.clearDoneTransientFields(task)) {
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
}
return task;
}
const validTargets = VALID_TRANSITIONS[task.column];
if (!validTargets.includes(toColumn)) {
throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
`Valid targets: ${validTargets.join(", ") || "none"}`,
);
}
const fromColumn = task.column;
if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) {
const mergeBlocker = getTaskMergeBlocker(task);
if (mergeBlocker) {
throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`);
}
}
const movedAt = internal.now ?? new Date().toISOString();
task.column = toColumn;
task.columnMovedAt = movedAt;
task.updatedAt = movedAt;
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt);
const segmentEndMs = Date.parse(task.columnMovedAt);
const segmentDeltaMs =
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
}
if (toColumn === "in-progress") {
task.cumulativeActiveMs ??= 0;
if (!task.firstExecutionAt) {
task.firstExecutionAt = task.columnMovedAt;
}
if (!task.executionStartedAt) {
task.executionStartedAt = task.columnMovedAt;
}
task.userPaused = undefined;
}
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
if (toColumn === "done") {
this.clearDoneTransientFields(task);
}
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review")
&& (toColumn === "todo" || toColumn === "triage");
if (isReopenToTodoOrTriage) {
if (!options?.preserveStatus) {
task.status = undefined;
task.error = undefined;
task.pausedReason = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
if (moveSource === "user" && toColumn === "todo") {
task.userPaused = true;
} else {
task.userPaused = undefined;
}
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress);
if (!options?.preserveWorktree) {
task.worktree = undefined;
}
// Clear transient fields when moving to done (matches moveToDone behavior)
if (toColumn === "done") {
this.clearDoneTransientFields(task);
if (!options?.preserveResumeState) {
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
} else {
task.executionCompletedAt = undefined;
}
// Clear transient fields when reopening/resetting a task into todo/triage.
// This ensures failed tasks don't show failed status after being moved for retry.
// Note: recovery metadata (recoveryRetryCount, nextRecoveryAt) is intentionally
// preserved here — the recovery-policy module manages those fields. They are
// only cleared on terminal transitions (in-review, done, archived).
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review")
&& (toColumn === "todo" || toColumn === "triage");
if (!preserveStepProgress) {
this.resetAllStepsToPending(task);
await this.resetPromptCheckboxes(dir);
}
}
if (isReopenToTodoOrTriage) {
if (!options?.preserveStatus) {
task.status = undefined;
task.error = undefined;
task.pausedReason = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
if (moveSource === "user" && toColumn === "todo") {
task.userPaused = true;
} else {
task.userPaused = undefined;
if (toColumn === "in-review") {
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
if (
(fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage"))
) {
task.workflowStepResults = undefined;
}
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) {
const allocator = options.allocateWorktree;
const allocated = await this.withWorktreeAllocationLock(async () => {
const others = await this.listTasks({ slim: true, includeArchived: false });
const reservedNames = new Set<string>();
for (const other of others) {
if (other.id === id || !other.worktree) continue;
const name = other.worktree.split("/").filter(Boolean).pop();
if (name) reservedNames.add(name);
}
return allocator(reservedNames);
});
if (allocated) {
task.worktree = allocated;
}
}
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress);
// Default: release the on-disk worktree directory on requeue. The
// checkout may have been removed, may now collide with another
// task's allocation, or may simply be abandoned by the bounce.
// `task.branch` is intentionally left intact so the next run can
// reattach to the same line of work — the executor's worktree
// creation path falls back to `git worktree add <path> <branch>`
// when the branch already exists, so any committed progress is
// preserved even though a fresh directory is allocated.
//
// Opt-out: internal bounces that immediately re-promote the task
// to in-progress on the same checkout (e.g. workflow-rerun) pass
// `preserveWorktree: true` so listeners never observe an interim
// `worktree=null` state.
if (!options?.preserveWorktree) {
task.worktree = undefined;
}
if (!options?.preserveResumeState) {
// Reset wall-clock runtime so the next run gets a fresh timer.
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
} else {
// executionCompletedAt is never set on an in-progress task; clear
// it defensively in case we are bouncing from done/in-review.
task.executionCompletedAt = undefined;
}
if (!preserveStepProgress) {
this.resetAllStepsToPending(task);
await this.resetPromptCheckboxes(dir);
}
let deletedAt: string | undefined;
let alreadyEnqueued = false;
this.db.transactionImmediate(() => {
deletedAt = this.getSoftDeletedWriteConflict(id, task);
if (deletedAt) {
return;
}
// Clear recovery metadata when task reaches in-review (successful completion)
if (toColumn === "in-review") {
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
this.upsertTaskWithFtsRecovery(task);
this.insertRunAuditEventRow({
taskId: id,
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
domain: "database",
mutationType: "task:move",
target: id,
metadata: {
from: fromColumn,
to: toColumn,
moveSource,
},
});
// Clear workflow step results when reopening from review/completed states.
// This ensures fresh workflow step runs on retry
if (
(fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage"))
) {
task.workflowStepResults = undefined;
}
// Full reset when sending an in-review task back to todo or triage
// (respec): discard prior branch/summary/recovery state so the next run
// starts from scratch.
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
// Atomic worktree allocation on transition to in-progress.
// Wrapped in withWorktreeAllocationLock so the read-tasks → pick-name
// sequence cannot interleave with another concurrent moveTask. The
// caller supplies the naming policy via the `allocateWorktree`
// callback; the store builds `reservedNames` here so the snapshot
// is fresh under the global lock.
if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) {
const allocator = options.allocateWorktree;
const allocated = await this.withWorktreeAllocationLock(async () => {
const others = await this.listTasks({ slim: true, includeArchived: false });
const reservedNames = new Set<string>();
for (const other of others) {
if (other.id === id || !other.worktree) continue;
const name = other.worktree.split("/").filter(Boolean).pop();
if (name) reservedNames.add(name);
}
return allocator(reservedNames);
if (toColumn === "in-review" && !internal.fromHandoff && options?.allowDirectInReviewMove !== true) {
this.insertRunAuditEventRow({
taskId: id,
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
domain: "database",
mutationType: "task:handoff-invariant-violation",
target: id,
metadata: {
taskId: id,
fromColumn,
callerStack: new Error().stack?.split("\n").slice(0, 8).join("\n"),
},
});
if (allocated) {
task.worktree = allocated;
}
}
await this.atomicWriteTaskJson(dir, task);
if (toColumn === "done") {
this.clearLinkedAgentTaskIds(id, task.updatedAt);
if (internal.fromHandoff) {
alreadyEnqueued = Boolean(this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id));
this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now });
this.insertRunAuditEventRow({
taskId: id,
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
domain: "database",
mutationType: "task:handoff",
target: id,
metadata: {
taskId: id,
fromColumn,
ownerAgentId: internal.ownerAgentId ?? null,
reason: internal.evidence?.reason,
runId: internal.runContext?.runId,
agentId: internal.runContext?.agentId,
alreadyEnqueued,
},
});
}
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource });
return task;
});
if (deletedAt) {
if (internal.fromHandoff) {
throw new HandoffInvariantViolationError(
id,
fromColumn,
`Cannot hand off ${id} to in-review because the task is deleted`,
);
}
this.throwSoftDeletedWriteBlocked(id, deletedAt, "moveTaskInternal", {
agentId: internal.runContext?.agentId,
runId: internal.runContext?.runId,
timestamp: movedAt,
});
}
await this.writeTaskJsonFile(dir, task);
if (toColumn === "done") {
this.clearLinkedAgentTaskIds(id, task.updatedAt);
}
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource });
return task;
}
private resetAllStepsToPending(task: Task): void {

View File

@@ -63,6 +63,30 @@ export type MergeQueueReleaseOutcome =
| { kind: "success" }
| { kind: "failure"; error: string };
export interface HandoffEvidence {
/** Reason text recorded on the run-audit event (for example "fn_task_done"). */
reason: string;
/** Optional run id captured for forensics. */
runId?: string;
/** Optional agent id captured for forensics. */
agentId?: string;
}
export interface HandoffToReviewOptions {
ownerAgentId: string | null;
evidence: HandoffEvidence;
moveOptions?: {
preserveResumeState?: boolean;
preserveProgress?: boolean;
preserveWorktree?: boolean;
preserveStatus?: boolean;
moveSource?: "user" | "engine";
skipMergeBlocker?: boolean;
};
/** Inject a clock for tests. */
now?: string;
}
/**
* Dashboard high-fan-out blocker threshold. A blocker is considered high impact
* when at least this many active todo tasks are waiting on it.
@@ -4974,6 +4998,8 @@ export type RunAuditMutationType =
| "mergeQueue:lease-acquired"
| "mergeQueue:lease-released"
| "mergeQueue:lease-expired"
| "task:handoff"
| "task:handoff-invariant-violation"
| (string & {});
/** Input for recording a run-audit event. */
@@ -4988,7 +5014,7 @@ export interface RunAuditEventInput {
runId: string;
/** The domain/category of the mutation. */
domain: RunAuditDomain;
/** Type of mutation (e.g., "task:update", "git:commit", "file:write"). */
/** Type of mutation (for example "task:update", "task:move", "task:handoff", "task:handoff-invariant-violation", "mergeQueue:enqueue", "git:commit", or "file:write"). */
mutationType: RunAuditMutationType;
/** Target of the mutation (e.g., task ID, file path, branch name). */
target: string;

View File

@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import * as worktreePool from "../worktree-pool.js";
import { createMockStore, mockedCreateFnAgent, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
import { TaskStore } from "@fusion/core";
import { createMockStore, mockedCreateFnAgent, mockedExec, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
function baseTask(overrides: Record<string, unknown> = {}) {
return {
@@ -32,6 +37,10 @@ async function setup(overrides: Record<string, unknown> = {}) {
store.moveTask.mockImplementation(async (id: string, column: string) => {
task = { ...task, id, column, paused: false, pausedByAgentId: null, status: null, error: null };
});
store.handoffToReview.mockImplementation(async (id: string) => {
task = { ...task, id, column: "in-review", paused: false, pausedByAgentId: null };
return task;
});
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
tool = customTools.find((t: any) => t.name === "fn_task_done");
@@ -140,3 +149,126 @@ describe("FN-4114 fn_task_done invariants", () => {
expect(store.updateStep).toHaveBeenCalled();
});
});
describe("FN-5241 executor handoff auditing", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-5241-executor-"));
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
resetExecutorMocks();
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
async function createExecutorTask(taskDoneRetryCount = 0) {
const created = await store.createTask({ description: "Invariant test", priority: "high" });
await store.moveTask(created.id, "todo");
await store.moveTask(created.id, "in-progress");
const worktreePath = join(rootDir, ".worktrees", "swift-falcon");
mkdirSync(worktreePath, { recursive: true });
const branch = `fusion/${created.id.toLowerCase()}`;
await store.updateTask(created.id, {
worktree: worktreePath,
branch,
baseCommitSha: "abc123",
taskDoneRetryCount,
steps: [{ name: "Step 1", status: "in-progress" }],
currentStep: 0,
});
const task = (await store.getTask(created.id))!;
return {
task: {
...task,
prompt: "# Test\n## Steps\n### Step 1: Implement\n- [ ] check",
},
worktreePath,
};
}
it("emits task:handoff and enqueues merge work on successful fn_task_done", async () => {
const { task, worktreePath } = await createExecutorTask();
mockedExec.mockImplementation(((cmd: string, _opts: unknown, cb?: (err: Error | null, stdout: string, stderr: string) => void) => {
if (!cb) return undefined as any;
if (cmd.includes("rev-parse --show-toplevel")) return cb(null, `${worktreePath}\n`, "");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return cb(null, `${task.branch}\n`, "");
if (cmd.includes("rev-list --count")) return cb(null, "1\n", "");
if (cmd.includes("rev-parse HEAD")) return cb(null, "def456\n", "");
return cb(null, "", "");
}) as any);
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => ({
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = customTools.find((tool: any) => tool.name === "fn_task_done");
await taskDoneTool.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
}) as any);
const executor = new TaskExecutor(store as any, rootDir);
await executor.execute(task as any);
expect((await store.getTask(task.id))?.column).toBe("in-review");
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id, priority: task.priority }),
]);
const handoff = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 10 })[0];
expect(handoff?.metadata).toMatchObject({
taskId: task.id,
reason: "fn_task_done",
alreadyEnqueued: false,
});
});
it("emits failed-status handoff auditing when no-fn_task_done retry budget is exhausted", async () => {
const { task, worktreePath } = await createExecutorTask(3);
mockedExec.mockImplementation(((cmd: string, _opts: unknown, cb?: (err: Error | null, stdout: string, stderr: string) => void) => {
if (!cb) return undefined as any;
if (cmd.includes("rev-parse --show-toplevel")) return cb(null, `${worktreePath}\n`, "");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return cb(null, `${task.branch}\n`, "");
if (cmd.includes("rev-list --count")) return cb(null, "1\n", "");
if (cmd.includes("rev-parse HEAD")) return cb(null, "def456\n", "");
return cb(null, "", "");
}) as any);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
} as any);
const executor = new TaskExecutor(store as any, rootDir);
await executor.execute(task as any);
const latest = await store.getTask(task.id);
expect(latest?.column).toBe("in-review");
expect(latest?.status).toBe("failed");
expect(String(latest?.error ?? "")).toContain("without calling fn_task_done");
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id, priority: task.priority }),
]);
const handoff = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 10 })[0];
expect(handoff?.metadata).toMatchObject({
taskId: task.id,
reason: "max-task-done-retries-exhausted",
alreadyEnqueued: false,
});
});
});

View File

@@ -321,6 +321,7 @@ export function createMockStore() {
}),
updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockResolvedValue({}),
handoffToReview: vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review")),
mergeTask: vi.fn().mockResolvedValue({}),
createTask: vi.fn().mockImplementation(async (input: Record<string, unknown>) => ({
id: "FN-002",

View File

@@ -28,6 +28,7 @@ function createStore(task: Task) {
return current;
}),
moveTask: vi.fn(async () => undefined),
enqueueMergeQueue: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async () => undefined),
_get: () => current,
@@ -52,6 +53,7 @@ describe("FN-4999 reliability interactions: completion-handoff-limbo", () => {
expect(requeueForAutoMerge).toHaveBeenCalledTimes(1);
expect(requeueForAutoMerge).toHaveBeenCalledWith("FN-4999-T");
expect(store.enqueueMergeQueue).toHaveBeenCalledWith("FN-4999-T");
expect(store.logEntry).toHaveBeenCalledWith("FN-4999-T", expect.stringMatching(/Auto-recovered \(FN-4999\)/));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({

View File

@@ -0,0 +1,146 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HandoffInvariantViolationError, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
function taskTempDir(): string {
return mkdtempSync(join(tmpdir(), "fn-5241-reliability-"));
}
describe("FN-5241 reliability interactions: in-review handoff atomic", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = taskTempDir();
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(() => {
try {
vi.restoreAllMocks();
store.close();
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
async function createInProgressTask(overrides: Record<string, unknown> = {}) {
const task = await store.createTask({ description: "handoff reliability", priority: "high" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
if (Object.keys(overrides).length > 0) {
await store.updateTask(task.id, overrides as any);
}
return (await store.getTask(task.id))!;
}
it("rolls back column move and queue insert when enqueueMergeQueue throws, then succeeds on retry", async () => {
const task = await createInProgressTask();
vi.spyOn(store, "enqueueMergeQueue").mockImplementationOnce(() => {
throw new Error("boom");
});
await expect(store.handoffToReview(task.id, {
ownerAgentId: "executor-agent",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "executor-agent" },
})).rejects.toThrow("boom");
expect((await store.getTask(task.id))?.column).toBe("in-progress");
expect(store.peekMergeQueue()).toHaveLength(0);
expect(store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 20 })).toHaveLength(0);
await store.handoffToReview(task.id, {
ownerAgentId: "executor-agent",
evidence: { reason: "fn_task_done", runId: "run-2", agentId: "executor-agent" },
});
expect((await store.getTask(task.id))?.column).toBe("in-review");
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id, priority: task.priority }),
]);
});
it("contains no direct moveTask(..., \"in-review\") writes outside allowlisted same-line comments", () => {
const regex = /moveTask\([^\n]+,\s*"in-review"\)/g;
for (const path of [
new URL("../../executor.ts", import.meta.url),
new URL("../../self-healing.ts", import.meta.url),
]) {
const source = readFileSync(path, "utf8");
const offenders = source
.split("\n")
.filter((line) => regex.test(line) && !/\/\/ handoff-invariant-violation-allowlist: .+/.test(line));
expect(offenders).toEqual([]);
regex.lastIndex = 0;
}
});
it("keeps autoMerge-false handoffs parked in in-review with queue state intact across self-healing sweeps", async () => {
await store.updateSettings({ autoMerge: false } as any);
const task = await createInProgressTask();
await store.handoffToReview(task.id, {
ownerAgentId: "executor-agent",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "executor-agent" },
});
const manager = new SelfHealingManager(store, { rootDir });
await manager.recoverCompletionHandoffLimbo();
expect(await manager.surfaceInReviewStalls()).toBe(0);
expect(await manager.surfaceInReviewStalled()).toBe(0);
const latest = await store.getTask(task.id);
expect(latest?.column).toBe("in-review");
expect(latest?.paused ?? false).toBe(false);
expect(latest?.status ?? null).toBeNull();
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id }),
]);
expect(store.getRunAuditEvents({ taskId: task.id, limit: 50 }).filter((event) => event.mutationType.startsWith("task:auto-recover"))).toEqual([]);
});
it("composes no-progress churn terminalization with atomic handoff + queue insertion", async () => {
const task = await createInProgressTask({ stuckKillCount: 2, lineageId: "lin-5241" });
const manager = new SelfHealingManager(store, { rootDir });
const result = await manager.checkStuckBudget(task.id, "no-progress-churn", { ignoredStepUpdateCount: 25 });
expect(result).toBe(false);
const latest = await store.getTask(task.id);
expect(latest?.column).toBe("in-review");
expect(latest?.status).toBe("failed");
expect(latest?.error).toMatch(/^STUCK_NO_PROGRESS_CHURN:/);
expect(store.peekMergeQueue()).toEqual([
expect.objectContaining({ taskId: task.id, priority: task.priority }),
]);
const handoff = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 10 })[0];
expect(handoff?.metadata).toMatchObject({
taskId: task.id,
reason: "stuck-no-progress-churn",
agentId: "self-healing",
ownerAgentId: null,
alreadyEnqueued: false,
});
});
it("rejects soft-deleted tasks without creating mergeQueue state", async () => {
const task = await createInProgressTask();
store.getDatabase().prepare('UPDATE tasks SET "deletedAt" = ? WHERE id = ?').run(
"2026-05-19T00:00:00.000Z",
task.id,
);
await expect(store.handoffToReview(task.id, {
ownerAgentId: "executor-agent",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "executor-agent" },
})).rejects.toBeInstanceOf(HandoffInvariantViolationError);
expect(store.peekMergeQueue()).toHaveLength(0);
expect(store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 10 })).toHaveLength(0);
});
});

View File

@@ -31,6 +31,11 @@ function createStore(task: Task, settings: Record<string, unknown> = {}): TaskSt
task.column = column as any;
task.updatedAt = new Date(Date.now()).toISOString();
});
(emitter as any).handoffToReview = vi.fn().mockImplementation(async (_taskId: string, _opts: any) => {
task.column = "in-review" as any;
task.updatedAt = new Date(Date.now()).toISOString();
return task;
});
(emitter as any).logEntry = vi.fn().mockImplementation(async (_taskId: string, action: string) => {
task.log = task.log ?? [];
task.log.push({ timestamp: new Date(Date.now()).toISOString(), action });
@@ -126,7 +131,10 @@ describe("reliability interactions: non-progress churn", () => {
expect(task.status).toBe("failed");
expect(task.column).toBe("in-review");
expect(task.error).toMatch(/^STUCK_NO_PROGRESS_CHURN:/);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "in-review");
expect(store.handoffToReview).toHaveBeenCalledWith(task.id, expect.objectContaining({
ownerAgentId: null,
evidence: expect.objectContaining({ reason: "stuck-no-progress-churn", agentId: "self-healing" }),
}));
manager.stop();
});

View File

@@ -270,7 +270,7 @@ const DEFAULT_SETTINGS: Settings = {
function createMockStore(overrides: Record<string, any> = {}) {
const listeners = new Map<string, Function[]>();
return {
const store = {
on: vi.fn((event: string, fn: Function) => {
const existing = listeners.get(event) || [];
existing.push(fn);
@@ -307,6 +307,8 @@ function createMockStore(overrides: Record<string, any> = {}) {
_listeners: listeners,
...overrides,
} as any;
store.handoffToReview ??= vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review"));
return store;
}
function makeTask(id: string, column: Column, overrides: Partial<Task> = {}): Task {

View File

@@ -56,6 +56,11 @@ function makeStore(
task.column = column;
return task;
}),
handoffToReview: vi.fn(async (id: string) => {
if (!task || id !== task.id) return null;
task.column = "in-review";
return task;
}),
logEntry: vi.fn(async () => undefined),
appendAgentLog: vi.fn(async () => undefined),
clearStaleExecutionStartBranchReferences: vi.fn(() => []),

View File

@@ -11,6 +11,9 @@ function createStore(): TaskStore & EventEmitter {
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).handoffToReview = vi.fn().mockImplementation(async (taskId: string) => {
await (emitter as any).moveTask(taskId, "in-review");
});
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;

View File

@@ -153,6 +153,8 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
updateTask: vi.fn().mockResolvedValue({} as Task),
logEntry: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
handoffToReview: vi.fn().mockResolvedValue(undefined),
enqueueMergeQueue: vi.fn().mockResolvedValue(undefined),
mergeTask: vi.fn().mockResolvedValue(undefined),
archiveTaskAndCleanup: vi.fn().mockResolvedValue({} as Task),
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }),
@@ -398,7 +400,10 @@ describe("SelfHealingManager", () => {
status: "failed",
error: "STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (7/6) after last reason=loop.",
});
expect(store.moveTask).toHaveBeenLastCalledWith("FN-001", "in-review");
expect(store.handoffToReview).toHaveBeenLastCalledWith("FN-001", expect.objectContaining({
ownerAgentId: null,
evidence: expect.objectContaining({ reason: "stuck-loop-exhausted", agentId: "self-healing" }),
}));
expect(store.logEntry).toHaveBeenLastCalledWith(
"FN-001",
"STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (7/6), last reason=loop. No further automatic retries will run. Manually retry, pause, or move the task to triage to resume work.",
@@ -423,7 +428,10 @@ describe("SelfHealingManager", () => {
status: "failed",
error: "STUCK_NO_PROGRESS_CHURN: detected 25 ignored step-update rebuffs after compact-and-resume failed to recover progress. Task is likely too large; decompose via fn_task_create child tasks or rescope. No further automatic retries will run.",
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
expect(store.handoffToReview).toHaveBeenCalledWith("FN-001", expect.objectContaining({
ownerAgentId: null,
evidence: expect.objectContaining({ reason: "stuck-no-progress-churn", agentId: "self-healing" }),
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"STUCK_NO_PROGRESS_CHURN: detected 25 ignored step-update rebuffs after compact-and-resume failed to recover progress. No further automatic retries will run. Pause the task, manually decompose the work via fn_task_create child tasks, or move it to triage to rescope.",
@@ -477,7 +485,7 @@ describe("SelfHealingManager", () => {
id: "FN-001",
stuckKillCount: 6,
} as unknown as Task);
(store.moveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("concurrent move"));
(store.handoffToReview as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("concurrent move"));
manager.start();
@@ -490,7 +498,7 @@ describe("SelfHealingManager", () => {
error: "STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (7/6) after last reason=loop.",
});
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(
expect.stringContaining("moveTask(\"in-review\") failed (concurrent move)"),
expect.stringContaining("handoffTaskToReview failed (concurrent move)"),
);
});
@@ -7424,7 +7432,9 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
paused: true,
pausedReason: "branch-conflict-unrecoverable",
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-503", "in-review");
expect(store.handoffToReview).toHaveBeenCalledWith("FN-503", expect.objectContaining({
evidence: expect.objectContaining({ reason: "branch-conflict-unrecoverable-repromote" }),
}));
});
it("preserves dirty worktree as recovery patch before unrecoverable escalation", async () => {
@@ -7448,7 +7458,9 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(store.moveTask).toHaveBeenCalledWith("FN-504", "in-review");
expect(store.handoffToReview).toHaveBeenCalledWith("FN-504", expect.objectContaining({
evidence: expect.objectContaining({ reason: "branch-conflict-unrecoverable-repromote" }),
}));
const recoveryDir = join(fixtureRoot, ".fusion", "recovery");
const files = await readdir(recoveryDir);
@@ -7472,7 +7484,9 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
paused: true,
pausedReason: "branch-conflict-unrecoverable",
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-502", "in-review");
expect(store.handoffToReview).toHaveBeenCalledWith("FN-502", expect.objectContaining({
evidence: expect.objectContaining({ reason: "branch-conflict-unrecoverable-repromote" }),
}));
});
});

View File

@@ -1208,6 +1208,27 @@ export class TaskExecutor {
return this.currentRunContexts.get(taskId);
}
/**
* Stable handoff reasons used on task:handoff audit events.
* Keep values greppable for executor/self-healing forensics: review-handoff-requested,
* completed-task-recovered, worktree-liveness-failed, step-session-completed,
* step-session-failed, transient-retries-exhausted, paused-after-completion,
* fn_task_done, fn_task_done-retry-completed, max-task-done-retries-exhausted,
* execution-failed, implicit-fn_task_done-refused, invariant-check-failed,
* fn_task_done-refused.
*/
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
const agentId = this.getRunContextFor(task.id)?.agentId;
return this.store.handoffToReview(task.id, {
ownerAgentId: agentId ?? null,
evidence: {
reason,
runId,
agentId,
},
});
}
private get modelRegistry(): ModelRegistry {
if (!this._modelRegistry) {
const authStorage = createFusionAuthStorage();
@@ -2366,7 +2387,7 @@ export class TaskExecutor {
// Move the task to in-review column (this will also emit task:moved event)
// The task:moved handler will clean up activeSessions
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "review-handoff-requested");
// Dispose the agent session (this may already be done by task:moved handler)
// but we do it here to be explicit
@@ -2455,7 +2476,7 @@ export class TaskExecutor {
this.recoveringCompleted.add(task.id);
await this.store.moveTask(task.id, "in-progress");
}
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "completed-task-recovered");
if (promotedFromTodo) {
this.recoveringCompleted.delete(task.id);
}
@@ -3050,7 +3071,7 @@ export class TaskExecutor {
});
await this.store.logEntry(task.id, `${failureMessage} — moved to in-review for inspection`, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "worktree-liveness-failed");
executorLog.log(`${task.id} worktree liveness failed — moved to in-review`);
}
this.options.onError?.(task, new Error(failureMessage));
@@ -3367,17 +3388,15 @@ export class TaskExecutor {
return;
}
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "step-session-completed");
this.clearCompletedTaskWatchdog(task.id);
// Audit trail: record task move (FN-1404)
await audit.database({ type: "task:move", target: task.id, metadata: { to: "in-review" } });
executorLog.log(`${task.id} completed (step-session) → in-review`);
this.options.onComplete?.(task);
} else {
const failedSteps = results.filter(r => !r.success);
const errorSummary = failedSteps.map(r => `Step ${r.stepIndex}: ${r.error || "unknown error"}`).join("; ");
await this.store.updateTask(task.id, { status: "failed", error: errorSummary });
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "step-session-failed");
executorLog.log(`${task.id} step-session failed → in-review: ${errorSummary}`);
this.options.onError?.(task, new Error(errorSummary));
}
@@ -3474,7 +3493,7 @@ export class TaskExecutor {
if (accumulatedStepTokenUsage) {
await this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage });
}
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "transient-retries-exhausted");
executorLog.log(`${task.id} transient retries exhausted → in-review`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
} else {
@@ -3484,7 +3503,7 @@ export class TaskExecutor {
if (accumulatedStepTokenUsage) {
await this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage });
}
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "step-session-failed");
executorLog.log(`${task.id} step-session execution failed → in-review`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
}
@@ -3956,7 +3975,7 @@ export class TaskExecutor {
executorLog.log(`${task.id} paused after completion (graceful session exit) — finalizing to in-review`);
await this.store.logEntry(task.id, "Execution paused after completion — finalizing to in-review");
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "paused-after-completion");
this.clearCompletedTaskWatchdog(task.id);
this.options.onComplete?.(task);
} else {
@@ -4064,7 +4083,7 @@ export class TaskExecutor {
}
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "fn_task_done");
this.clearCompletedTaskWatchdog(task.id);
executorLog.log(`${task.id} completed → in-review`);
this.options.onComplete?.(task);
@@ -4294,7 +4313,7 @@ export class TaskExecutor {
}
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "fn_task_done-retry-completed");
this.clearCompletedTaskWatchdog(task.id);
executorLog.log(`${task.id} completed on retry → in-review`);
this.options.onComplete?.(task);
@@ -4347,7 +4366,7 @@ export class TaskExecutor {
await this.store.updateTask(task.id, { status: "failed", error: errorMessage });
await this.store.logEntry(task.id, `${errorMessage} — moved to in-review for inspection`, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "max-task-done-retries-exhausted");
executorLog.log(`${task.id} failed after ${MAX_TASK_DONE_SESSION_RETRIES} retries — no fn_task_done → in-review`);
}
this.options.onError?.(task, new Error(errorMessage));
@@ -4442,7 +4461,7 @@ export class TaskExecutor {
executorLog.log(`${task.id} paused after completion — finalizing to in-review`);
await this.store.logEntry(task.id, "Execution paused after completion — finalizing to in-review", undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "paused-after-completion");
this.options.onComplete?.(task);
} else {
executorLog.log(`${task.id} paused — moving to todo`);
@@ -4889,7 +4908,7 @@ export class TaskExecutor {
nextRecoveryAt: null,
});
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "transient-retries-exhausted");
executorLog.log(`${task.id} transient retries exhausted → in-review`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
return;
@@ -4901,7 +4920,7 @@ export class TaskExecutor {
await this.store.logEntry(task.id, `Execution failed: ${terminalError}`, errorStack ?? errorDetail, this.getRunContextFor(task.id));
await this.store.updateTask(task.id, { status: "failed", error: terminalError });
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "execution-failed");
executorLog.log(`${task.id} execution failed → in-review`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
}
@@ -5578,7 +5597,7 @@ export class TaskExecutor {
});
await this.store.logEntry(task.id, `${refusal.message} — moved to in-review for inspection`, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task, "implicit-fn_task_done-refused");
}
this.deleteActiveSession(task.id);
@@ -5659,7 +5678,14 @@ export class TaskExecutor {
});
await store.logEntry(taskId, `${refusalMessage} — moved to in-review for inspection`, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(taskId);
await store.moveTask(taskId, "in-review");
await store.handoffToReview(taskId, {
ownerAgentId: this.getRunContextFor(task.id)?.agentId ?? null,
evidence: {
reason: "invariant-check-failed",
runId: this.getRunContextFor(task.id)?.runId,
agentId: this.getRunContextFor(task.id)?.agentId,
},
});
executorLog.log(`${taskId} failed invariant check — moved to in-review`);
}
@@ -5710,7 +5736,14 @@ export class TaskExecutor {
});
await store.logEntry(taskId, `${refusalMessage} — moved to in-review for inspection`, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(taskId);
await store.moveTask(taskId, "in-review");
await store.handoffToReview(taskId, {
ownerAgentId: this.getRunContextFor(task.id)?.agentId ?? null,
evidence: {
reason: "fn_task_done-refused",
runId: this.getRunContextFor(task.id)?.runId,
agentId: this.getRunContextFor(task.id)?.agentId,
},
});
executorLog.log(`${taskId} fn_task_done refusal (${taskDoneRefusal.refusalClass}) — moved to in-review for inspection`);
}

View File

@@ -569,6 +569,17 @@ export class SelfHealingManager {
} as MergeResult);
}
private async handoffTaskToReview(taskId: string, reason: string): Promise<Task> {
return this.store.handoffToReview(taskId, {
ownerAgentId: null,
evidence: {
reason,
runId: generateSyntheticRunId("self-heal-handoff", taskId),
agentId: "self-healing",
},
});
}
// ── Lifecycle ───────────────────────────────────────────────────────
start(): void {
@@ -842,10 +853,10 @@ export class SelfHealingManager {
error: churnError,
});
try {
await this.store.moveTask(taskId, "in-review");
await this.handoffTaskToReview(taskId, "stuck-no-progress-churn");
} catch (moveErr: unknown) {
const moveErrMessage = moveErr instanceof Error ? moveErr.message : String(moveErr);
log.warn(`${taskId} moveTask("in-review") failed (${moveErrMessage}) after STUCK_NO_PROGRESS_CHURN terminalization — task already marked failed, not re-queuing`);
log.warn(`${taskId} handoffTaskToReview failed (${moveErrMessage}) after STUCK_NO_PROGRESS_CHURN terminalization — task already marked failed, not re-queuing`);
}
await this.store.logEntry(
taskId,
@@ -885,12 +896,12 @@ export class SelfHealingManager {
error: exhaustedError,
});
try {
await this.store.moveTask(taskId, "in-review");
await this.handoffTaskToReview(taskId, "stuck-loop-exhausted");
} catch (moveErr: unknown) {
// moveTask may fail if task was concurrently moved (e.g., dep-abort).
// The task is already marked failed — don't allow requeue.
const moveErrMessage = moveErr instanceof Error ? moveErr.message : String(moveErr);
log.warn(`${taskId} moveTask("in-review") failed (${moveErrMessage}) after STUCK_LOOP_EXHAUSTED terminalization — task already marked failed, not re-queuing`);
log.warn(`${taskId} handoffTaskToReview failed (${moveErrMessage}) after STUCK_LOOP_EXHAUSTED terminalization — task already marked failed, not re-queuing`);
}
await this.store.logEntry(
taskId,
@@ -1746,7 +1757,7 @@ export class SelfHealingManager {
paused: true,
pausedReason: "branch-conflict-unrecoverable",
});
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task.id, "branch-conflict-unrecoverable-repromote");
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable — ${message}`);
}
return withPerPr({ outcome: "paused-unrecoverable", reason: message });
@@ -2122,7 +2133,7 @@ export class SelfHealingManager {
paused: true,
pausedReason: "branch-conflict-unrecoverable",
});
await this.store.moveTask(task.id, "in-review");
await this.handoffTaskToReview(task.id, "branch-conflict-unrecoverable-repromote");
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable — ${message}`);
}
}
@@ -5257,6 +5268,13 @@ export class SelfHealingManager {
await this.store.logEntry(task.id, "Auto-recovered (FN-4999): task in 'in-review' past handoff grace with no merge fan-out — re-emitting auto-merge handoff");
if (this.options.requeueForAutoMerge) {
try {
await this.store.enqueueMergeQueue(task.id);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`recoverCompletionHandoffLimbo: enqueue failed for ${task.id}: ${errorMessage}`);
continue;
}
await this.options.requeueForAutoMerge(task.id);
} else {
log.warn(`recoverCompletionHandoffLimbo: requeueForAutoMerge callback missing for ${task.id}`);