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:
committed by
gsxdsm
parent
b7ddfc9d20
commit
93b11c6c0c
216
packages/core/src/__tests__/store-handoff-to-review.test.ts
Normal file
216
packages/core/src/__tests__/store-handoff-to-review.test.ts
Normal 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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user