feat(FN-4401): complete remaining auto-claim snapshot integration

Fusion-Task-Id: FN-4401
Fusion-Task-Lineage: c1b6c497-b22c-48d5-b1c8-299877bf09ac
This commit is contained in:
Fusion
2026-05-14 00:58:06 -07:00
committed by gsxdsm
parent b8f5a237bd
commit f860618657
16 changed files with 299 additions and 78 deletions

View File

@@ -83,6 +83,20 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
});
it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5);
expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true);
expect(isGlobalSettingsKey("autoClaimCandidatesInPrompt")).toBe(false);
});
it("documents autoClaimCandidatesInPrompt expected integer range", () => {
const inRange = [0, 1, 5, 10];
const outOfRange = [-1, 11, 100];
expect(inRange.every((value) => Number.isInteger(value) && value >= 0 && value <= 10)).toBe(true);
expect(outOfRange.every((value) => Number.isInteger(value) && value >= 0 && value <= 10)).toBe(false);
});
it("defaults sibling branch rename escape hatch to disabled", () => {
expect(DEFAULT_PROJECT_SETTINGS.executorAllowSiblingBranchRename).toBe(false);
expect(isProjectSettingsKey("executorAllowSiblingBranchRename")).toBe(true);

View File

@@ -171,6 +171,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,
autoClaimCandidatesInPrompt: 5,
groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true,

View File

@@ -2093,6 +2093,8 @@ export interface ProjectSettings {
* For example, 0.5 halves the interval (faster checks), 2.0 doubles it (slower checks).
* Must be > 0. Default: 1 (no change). */
heartbeatMultiplier?: number;
/** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */
autoClaimCandidatesInPrompt?: number;
groupOverlappingFiles: boolean;
/** File/directory paths to ignore when evaluating overlap serialization.
* Entries are project-relative paths (for example: `docs/README.md`, `docs/`, `generated/*`).

View File

@@ -1560,6 +1560,15 @@
/* === Agent Detail View Mobile Responsive === */
@media (max-width: 768px) {
.agent-heartbeat-preset-row {
flex-direction: column;
align-items: stretch;
}
.agent-heartbeat-preset-btn {
width: 100%;
}
.config-runtime-tabs {
grid-template-columns: 1fr;
}

View File

@@ -3218,6 +3218,9 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
nextValues.messageResponseMode = rc.messageResponseMode;
}
if (rc.autoClaimCandidatesInPrompt !== undefined && rc.autoClaimCandidatesInPrompt !== null) {
nextValues.autoClaimCandidatesInPrompt = String(rc.autoClaimCandidatesInPrompt);
}
return nextValues;
}
@@ -3901,7 +3904,7 @@ function ConfigTab({
if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true;
if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true;
if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode", "autoClaimCandidatesInPrompt"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
@@ -4044,6 +4047,14 @@ function ConfigTab({
}
}
const autoClaimCandidatesInPromptRaw = heartbeatValues.autoClaimCandidatesInPrompt?.trim();
if (autoClaimCandidatesInPromptRaw) {
const num = Number(autoClaimCandidatesInPromptRaw);
if (!Number.isInteger(num) || num < 0 || num > 10) {
nextErrors.autoClaimCandidatesInPrompt = "\"Auto-claim candidates in prompt\" must be an integer between 0 and 10";
}
}
const messageResponseModeForValidation = heartbeatValues.messageResponseMode?.trim();
if (messageResponseModeForValidation && !["immediate", "on-heartbeat"].includes(messageResponseModeForValidation)) {
nextErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
@@ -4127,13 +4138,13 @@ function ConfigTab({
newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup;
newRuntimeConfig.allowParallelExecution = allowParallelExecution;
newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "autoClaimCandidatesInPrompt"] as const) {
const raw = heartbeatValues[key]?.trim();
if (!raw) {
delete newRuntimeConfig[key];
} else {
const num = Number(raw);
newRuntimeConfig[key] = key === "maxConcurrentRuns" ? num : num * 1000;
newRuntimeConfig[key] = key === "maxConcurrentRuns" || key === "autoClaimCandidatesInPrompt" ? num : num * 1000;
}
}
@@ -4599,6 +4610,39 @@ function ConfigTab({
</p>
<div className="config-fields">
<div className="config-field agent-heartbeat-auto-claim-card">
<div className="agent-heartbeat-preset-row">
<div>
<label className="agent-heartbeat-preset-label">Coordination-only agent</label>
<span className="config-hint">Disables auto-claim and removes the candidate section from heartbeat prompts. Recommended for routing/CEO-style agents.</span>
</div>
<button
type="button"
className="btn btn-sm agent-heartbeat-preset-btn"
onClick={() => {
setAutoClaimRelevantTasksEnabled(false);
setHeartbeatValues((prev) => ({ ...prev, autoClaimCandidatesInPrompt: "0" }));
void scheduleAutoSave();
}}
>
Apply preset
</button>
</div>
<label className="checkbox-label" htmlFor="hb-autoClaimRelevantTasks">
<input
id="hb-autoClaimRelevantTasks"
type="checkbox"
checked={autoClaimRelevantTasksEnabled}
onChange={(e) => {
setAutoClaimRelevantTasksEnabled(e.target.checked);
void scheduleAutoSave();
}}
/>
Auto-Claim Relevant Tasks
</label>
<span className="config-hint">When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent&apos;s role and soul.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-enabled">
<input
@@ -4615,22 +4659,6 @@ function ConfigTab({
<span className="config-hint">When enabled, this agent receives scheduled heartbeat runs based on its interval.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-autoClaimRelevantTasks">
<input
id="hb-autoClaimRelevantTasks"
type="checkbox"
checked={autoClaimRelevantTasksEnabled}
onChange={(e) => {
setAutoClaimRelevantTasksEnabled(e.target.checked);
void scheduleAutoSave();
}}
/>
Auto-Claim Relevant Tasks
</label>
<span className="config-hint">When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent&apos;s role and soul.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-runMissedHeartbeatOnStartup">
<input

View File

@@ -977,6 +977,45 @@ describe("Advanced Settings", () => {
});
});
it("applies coordination-only preset and persists disabled auto-claim", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
enabled: true,
autoClaimRelevantTasks: true,
autoClaimCandidatesInPrompt: 5,
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
await user.click(await screen.findByRole("button", { name: "Apply preset" }));
expect((screen.getByLabelText("Auto-Claim Relevant Tasks") as HTMLInputElement).checked).toBe(false);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({
autoClaimRelevantTasks: false,
autoClaimCandidatesInPrompt: 0,
}),
}),
undefined,
);
});
});
it("defaults allow-parallel-execution toggle to checked when runtimeConfig.allowParallelExecution is undefined", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {

View File

@@ -12,15 +12,15 @@ function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
dependencies: overrides.dependencies ?? [],
labels: overrides.labels ?? [],
comments: overrides.comments ?? [],
activityLog: overrides.activityLog ?? [],
metadata: overrides.metadata ?? {},
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
log: overrides.log ?? [],
assignedAgentId: overrides.assignedAgentId,
checkedOutBy: overrides.checkedOutBy,
paused: overrides.paused,
columnMovedAt: overrides.columnMovedAt,
} as Task;
} as unknown as Task;
}
describe("AutoClaimSnapshotManager", () => {

View File

@@ -896,6 +896,51 @@ describe("executeHeartbeat", () => {
expect(store.claimTaskForAgent).not.toHaveBeenCalled();
});
it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => {
const listTasks = vi.fn().mockResolvedValue([
{
id: "FN-CANDIDATE",
description: "executor reliability follow-up",
title: "Executor reliability",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail,
]);
mockTaskStore = createMockTaskStore({ listTasks });
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "executor reliability owner" });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await Promise.all([
monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }),
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }),
]);
expect(listTasks).toHaveBeenCalledTimes(1);
});
it("omits candidate section when autoClaimCandidatesInPrompt resolves to zero", async () => {
const store = createStoreWithAgentForExec({
taskId: undefined,
soul: "executor reliability owner",
runtimeConfig: { autoClaimRelevantTasks: true, autoClaimCandidatesInPrompt: 0 },
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
expect(executionPrompt).toContain("auto-claim relevant tasks: disabled (prompt-suppressed)");
expect(executionPrompt).not.toContain("Open unowned tasks you may auto-claim");
});
it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession();

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { Scheduler } from "../scheduler.js";
function createStore() {
const listeners = new Map<string, ((payload: unknown) => void)[]>();
const on = vi.fn((event: string, listener: (payload: unknown) => void) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
});
const store = {
on,
off: vi.fn(),
} as unknown as TaskStore;
const emit = (event: string, payload: unknown) => {
for (const listener of listeners.get(event) ?? []) {
listener(payload);
}
};
return { store, emit };
}
describe("Scheduler auto-claim snapshot invalidation", () => {
it("invalidates on task:created and task:updated", () => {
const invalidate = vi.fn();
const { store, emit } = createStore();
new Scheduler(store, { snapshotManager: { invalidate } as any });
emit("task:created", { task: { id: "FN-1" } });
emit("task:updated", { id: "FN-1" });
expect(invalidate).toHaveBeenCalledWith("task:created");
expect(invalidate).toHaveBeenCalledWith("task:updated");
});
it("invalidates task:moved only when todo is source or destination", () => {
const invalidate = vi.fn();
const { store, emit } = createStore();
new Scheduler(store, { snapshotManager: { invalidate } as any });
emit("task:moved", { task: { id: "FN-1" }, from: "todo", to: "in-progress" });
emit("task:moved", { task: { id: "FN-2" }, from: "in-progress", to: "todo" });
emit("task:moved", { task: { id: "FN-3" }, from: "in-review", to: "done" });
expect(invalidate).toHaveBeenCalledWith("task:moved:todo->in-progress");
expect(invalidate).toHaveBeenCalledWith("task:moved:in-progress->todo");
expect(invalidate).toHaveBeenCalledTimes(2);
});
});

View File

@@ -18,6 +18,7 @@
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode } from "@fusion/core";
import { AutoClaimSnapshotManager, type AutoClaimCandidate } from "./auto-claim-snapshot.js";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -89,6 +90,8 @@ export interface HeartbeatMonitorOptions {
onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
/** Callback when a run completes */
onRunCompleted?: (agentId: string, run: AgentHeartbeatRun) => void;
/** Project-wide auto-claim snapshot manager. */
snapshotManager?: AutoClaimSnapshotManager;
/** TaskStore for fn_task_create and fn_task_log tools during heartbeat execution.
* When not provided, executeHeartbeat() will throw. */
taskStore?: TaskStore;
@@ -232,7 +235,16 @@ function isAutoClaimRelevantTasksEnabled(agent: Agent): boolean {
return runtimeConfig.autoClaimRelevantTasks !== false;
}
type RelevanceScorableTask = Pick<TaskDetail, "title" | "description">;
function resolveAutoClaimCandidatesInPromptLimit(agent: Agent, settings?: Settings): number {
const runtimeConfig = (agent.runtimeConfig ?? {}) as AgentHeartbeatConfig;
const perAgent = runtimeConfig.autoClaimCandidatesInPrompt;
const projectValue = settings?.autoClaimCandidatesInPrompt;
const raw = typeof perAgent === "number" ? perAgent : (typeof projectValue === "number" ? projectValue : 5);
const integer = Number.isFinite(raw) ? Math.trunc(raw) : 5;
return Math.max(0, Math.min(10, integer));
}
type RelevanceScorableTask = { title?: string | null; description: string };
const agentSoulWordsCache = new Map<string, { soulSnapshot: string; words: readonly string[] }>();
@@ -649,6 +661,7 @@ export class HeartbeatMonitor {
private reflectionService?: AgentReflectionService;
private selfImproveService?: SelfImproveServiceLike;
private approvalRequestStore?: ApprovalRequestStore;
private snapshotManager?: AutoClaimSnapshotManager;
private trackedAgents: Map<string, TrackedAgent> = new Map();
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
@@ -677,6 +690,7 @@ export class HeartbeatMonitor {
this.reflectionStore = options.reflectionStore;
this.reflectionService = options.reflectionService;
this.selfImproveService = options.selfImproveService;
this.snapshotManager = options.snapshotManager ?? (this.taskStore ? new AutoClaimSnapshotManager({ taskStore: this.taskStore }) : undefined);
}
getChatStore(): ChatStore | undefined {
@@ -1727,58 +1741,38 @@ export class HeartbeatMonitor {
engineRunContext.taskId = taskId;
}
let autoClaimCandidates: TaskDetail[] = [];
let autoClaimCandidates: AutoClaimCandidate[] = [];
const autoClaimEnabled = isAutoClaimRelevantTasksEnabled(agent);
if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled) {
const listTasks = (taskStore as TaskStore & { listTasks?: (options?: { slim?: boolean }) => Promise<TaskDetail[]> }).listTasks;
if (typeof listTasks === "function") {
try {
const allTasks = await listTasks.call(taskStore, { slim: true });
const tasksById = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
const openCandidates = allTasks
.filter((candidate) => (
candidate.column === "todo"
&& candidate.paused !== true
&& !candidate.assignedAgentId
&& !candidate.checkedOutBy
&& candidate.dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived";
})
))
.sort((a, b) => {
const aSortAt = a.columnMovedAt ?? a.createdAt;
const bSortAt = b.columnMovedAt ?? b.createdAt;
return aSortAt.localeCompare(bSortAt);
})
.slice(0, 10);
const roleCompatibleCandidates = openCandidates.filter((candidate) => canAgentTakeImplementationTask(agent, candidate));
const skippedIncompatibleCount = openCandidates.length - roleCompatibleCandidates.length;
if (skippedIncompatibleCount > 0) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`,
);
}
autoClaimCandidates = roleCompatibleCandidates;
const ranked = roleCompatibleCandidates
.map((candidate) => ({ candidate, score: taskRelevanceScore(agent, candidate as TaskDetail) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || (a.candidate.columnMovedAt ?? a.candidate.createdAt).localeCompare(b.candidate.columnMovedAt ?? b.candidate.createdAt));
if (ranked.length > 0) {
const claimResult = await this.store.claimTaskForAgent(agentId, ranked[0].candidate.id, runContext);
if (claimResult.ok) {
taskId = ranked[0].candidate.id;
heartbeatLog.log(`Agent ${agentId} auto-claimed relevant task ${taskId}`);
} else {
heartbeatLog.log(`Agent ${agentId} auto-claim skipped (${claimResult.reason})`);
}
}
} catch (autoClaimError) {
heartbeatLog.warn(`Auto-claim scan failed for ${agentId}: ${autoClaimError instanceof Error ? autoClaimError.message : String(autoClaimError)}`);
if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled && this.snapshotManager) {
try {
const snapshot = await this.snapshotManager.getSnapshot();
const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate));
const skippedIncompatibleCount = snapshot.tasks.length - roleCompatibleCandidates.length;
if (skippedIncompatibleCount > 0) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`,
);
}
autoClaimCandidates = roleCompatibleCandidates;
const ranked = roleCompatibleCandidates
.map((candidate) => ({ candidate, score: candidate.baseScore + taskRelevanceScore(agent, candidate) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || (a.candidate.columnMovedAt ?? a.candidate.createdAt).localeCompare(b.candidate.columnMovedAt ?? b.candidate.createdAt));
if (ranked.length > 0) {
const winnerId = ranked[0].candidate.id;
const winner = await taskStore.getTask(winnerId);
const claimResult = await this.store.claimTaskForAgent(agentId, winner.id, runContext);
if (claimResult.ok) {
taskId = winner.id;
heartbeatLog.log(`Agent ${agentId} auto-claimed relevant task ${taskId}`);
} else {
heartbeatLog.log(`Agent ${agentId} auto-claim skipped (${claimResult.reason})`);
}
}
} catch (autoClaimError) {
heartbeatLog.warn(`Auto-claim scan failed for ${agentId}: ${autoClaimError instanceof Error ? autoClaimError.message : String(autoClaimError)}`);
}
}
if (!taskId) {
@@ -2319,13 +2313,16 @@ export class HeartbeatMonitor {
);
}
const candidateLines = autoClaimCandidates.length > 0
const promptCandidateLimit = resolveAutoClaimCandidatesInPromptLimit(agent, heartbeatModelSettings);
const candidateLines = promptCandidateLimit > 0
? [
"",
"Open Task Candidates (auto-claim scan):",
...autoClaimCandidates.slice(0, 10).map((candidate) => `- ${candidate.id}: ${candidate.title ?? candidate.description.slice(0, 80)}`),
...autoClaimCandidates
.slice(0, promptCandidateLimit)
.map((candidate) => `- ${candidate.id}: ${candidate.title ?? candidate.descriptionFirstLine}`),
]
: ["", "Open Task Candidates (auto-claim scan): none found"];
: [];
executionPrompt = [
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
@@ -2343,7 +2340,7 @@ export class HeartbeatMonitor {
`- assigned task: none`,
`- pending messages: ${pendingMessages.length}`,
`- pending room messages: ${pendingRoomMessages.total}`,
`- auto-claim relevant tasks: ${autoClaimEnabled ? "enabled" : "disabled"}`,
`- auto-claim relevant tasks: ${autoClaimEnabled ? (promptCandidateLimit === 0 ? "disabled (prompt-suppressed)" : "enabled") : "disabled"}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on",
@@ -2385,6 +2382,7 @@ export class HeartbeatMonitor {
"",
"Call fn_heartbeat_done when finished.",
].join("\n");
heartbeatLog.log(`[auto-claim-prompt] agent=${agentId} chars=${executionPrompt.length} count=${Math.min(promptCandidateLimit, autoClaimCandidates.length)}`);
} else {
// Task-scoped heartbeat: agent has an assigned task
const taskTitle = taskDetail!.title ?? taskDetail!.description.slice(0, 100);

View File

@@ -19,6 +19,7 @@ import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
import { WorktreePool, isGitRepository } from "../worktree-pool.js";
import { AgentSemaphore } from "../concurrency.js";
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js";
import { AutoClaimSnapshotManager } from "../auto-claim-snapshot.js";
import { RoutineRunner, type RoutineRunnerOptions } from "../routine-runner.js";
import { RoutineScheduler } from "../routine-scheduler.js";
import { createAiPromptExecutor } from "../cron-runner.js";
@@ -309,6 +310,8 @@ export class InProcessRuntime
getExecutingTaskIds: () => this.executor?.getExecutingTaskIds() ?? new Set<string>(),
});
const autoClaimSnapshotManager = new AutoClaimSnapshotManager({ taskStore: this.taskStore });
this.scheduler = new Scheduler(this.taskStore, {
maxConcurrent: this.config.maxConcurrent,
maxWorktrees: this.config.maxWorktrees,
@@ -332,6 +335,7 @@ export class InProcessRuntime
const mappedPath = await this.centralCore.getProjectNodePath(this.config.projectId, nodeId);
return validateProjectNodeMapping({ nodeId, mappedPath });
},
snapshotManager: autoClaimSnapshotManager,
});
@@ -464,6 +468,7 @@ export class InProcessRuntime
reflectionStore: reflectionStoreForService,
reflectionService,
selfImproveService,
snapshotManager: autoClaimSnapshotManager,
onMissed: (agentId, reason) => {
runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`);
},

View File

@@ -23,6 +23,7 @@ import { applyUnavailableNodePolicy } from "./node-routing-policy.js";
import type { NodeDispatchValidationResult } from "./node-dispatch-validation.js";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { selectPermanentAgentForTask } from "./agent-assignment.js";
import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -145,6 +146,8 @@ export interface SchedulerOptions {
nodeHealthMonitor?: import("./node-health-monitor.js").NodeHealthMonitor;
/** Optional dispatch validator used to block dispatch on configuration issues before health policy checks. */
validateNodeDispatch?: (nodeId: string) => Promise<NodeDispatchValidationResult>;
/** Optional shared auto-claim snapshot manager for invalidation on task mutations. */
snapshotManager?: AutoClaimSnapshotManager;
}
/**
@@ -202,6 +205,7 @@ export class Scheduler {
* This reduces latency from up to 15 seconds to near-instant.
*/
this.store.on("task:created", () => {
this.options.snapshotManager?.invalidate("task:created");
schedulerLog.log("Task created — triggering scheduling");
this.schedule();
});
@@ -241,6 +245,9 @@ export class Scheduler {
* update feature status and potentially activate next pending slice.
*/
this.store.on("task:moved", async ({ task, from, to }) => {
if (from === "todo" || to === "todo") {
this.options.snapshotManager?.invalidate(`task:moved:${from}->${to}`);
}
// PR Monitoring
if (this.options.prMonitor) {
if (to === "in-review" && task.prInfo) {
@@ -351,6 +358,7 @@ export class Scheduler {
* Also detects task-level unpause transitions and triggers immediate scheduling.
*/
this.store.on("task:updated", (task) => {
this.options.snapshotManager?.invalidate("task:updated");
// Track mission failure signals before moveTask clears failure metadata.
if (task.sliceId && task.column === "in-progress" && task.status === "failed") {
this.failedTaskIds.add(task.id);