feat(agents): decouple heartbeat from task state, add allowParallelExecution

Permanent agents now run heartbeats regardless of bound-task block state.
The prior queued+blockedBy early-exit and its state-tracking machinery are
removed; HEARTBEAT_SYSTEM_PROMPT is rewritten to scope heartbeats to
ambient coordination (messaging, memory, finding work, delegation,
surfacing/chasing blockers, status). Task body work continues via the
executor path. Ephemeral agents are unchanged.

New allowParallelExecution flag (default true, permanent agents only) on
AgentHeartbeatConfig. When false, heartbeat and executor paths serialize
symmetrically: a heartbeat will not start while the agent's bound task
has an active executor session, and an executor session will not start
while the agent has an active heartbeat run. Either side re-dispatches
the other's deferred work on completion. UI toggle surfaces in the
agent's Heartbeat Settings tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 22:04:06 -07:00
parent bb32765438
commit b4b7a8a212
16 changed files with 255 additions and 313 deletions

View File

@@ -1717,7 +1717,6 @@ describe("TaskExecutor worktree recovery", () => {
mockedExistsSync.mockReturnValue(true);
// Mock git worktree list to not include our path
let callCount = 0;
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree list")) {
@@ -3171,7 +3170,7 @@ describe("TaskExecutor pause behavior", () => {
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const _executor = new TaskExecutor(store, "/tmp/test");
// Simulate unpause of an in-progress task that has no active session
// (e.g., engine restarted while task was paused in-progress)
@@ -3291,7 +3290,7 @@ describe("TaskExecutor pause behavior", () => {
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const _executor = new TaskExecutor(store, "/tmp/test");
store._trigger("task:updated", {
id: "FN-001",
@@ -3394,7 +3393,7 @@ describe("TaskExecutor pause behavior", () => {
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const _executor = new TaskExecutor(store, "/tmp/test");
// Unpause a todo task — executor should NOT try to execute it
store._trigger("task:updated", {

View File

@@ -1,24 +1,17 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
isBlockedStateDuplicate,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core";
import { createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {
@@ -870,67 +863,11 @@ describe("executeHeartbeat", () => {
});
});
describe("blocked-task dedup", () => {
const buildContextHash = (blockedBy: string, taskDetail: Partial<TaskDetail>): string => {
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
const lastCommentId = taskDetail.comments?.at(-1)?.id;
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
return Buffer.from(
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
)
.toString("base64")
.slice(0, 16);
};
it("skips duplicate blocked comments when blocked snapshot is unchanged", async () => {
describe("blocked-task heartbeat: runs through without early exit", () => {
it("invokes the model when task is blocked (no early exit)", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
const taskDetail = {
id: "FN-BLOCKED",
title: "Blocked Task",
description: "Blocked task description",
prompt: "",
status: "queued",
blockedBy: "FN-DEP-1",
comments: [{ id: "comment-1", text: "Still blocked", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail;
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-1",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: buildContextHash("FN-DEP-1", taskDetail),
});
mockTaskStore = createMockTaskStore({
getTask: vi.fn().mockResolvedValue(taskDetail),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.resultJson).toEqual({ reason: "blocked_duplicate", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
expect(mockTaskStore.addComment).not.toHaveBeenCalled();
expect(store.setLastBlockedState).not.toHaveBeenCalled();
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("re-logs blocked state when new comments change context hash", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-1",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: "stale-context-hash",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const taskDetail = {
id: "FN-BLOCKED",
@@ -939,7 +876,7 @@ describe("executeHeartbeat", () => {
prompt: "",
status: "queued",
blockedBy: "FN-DEP-1",
comments: [{ id: "comment-2", text: "New context", author: "user", createdAt: "2026-01-02T00:00:00.000Z" }],
comments: [],
steeringComments: [],
steps: [],
column: "todo",
@@ -955,31 +892,25 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.resultJson).toEqual({ reason: "blocked", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
expect(store.setLastBlockedState).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" }),
);
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
// The heartbeat must fall through to model invocation — no early exit reason
expect(result.resultJson).not.toEqual(expect.objectContaining({ reason: "blocked" }));
expect(result.resultJson).not.toEqual(expect.objectContaining({ reason: "blocked_duplicate" }));
expect(mockedCreateFnAgent).toHaveBeenCalled();
expect(mockSession.prompt).toHaveBeenCalled();
});
it("treats changed blockedBy as a new blocked state", async () => {
it("includes blockedBy in the prompt context when task is blocked", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-OLD",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: "samehash",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const taskDetail = {
id: "FN-BLOCKED",
title: "Blocked Task",
description: "Blocked task description",
title: "Blocked by dependency",
description: "Task blocked on FN-DEP-99",
prompt: "",
status: "queued",
blockedBy: "FN-DEP-NEW",
blockedBy: "FN-DEP-99",
comments: [],
steeringComments: [],
steps: [],
@@ -996,42 +927,9 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
expect(store.setLastBlockedState).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ blockedBy: "FN-DEP-NEW" }),
);
});
it("clears blocked state when task is no longer blocked", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-READY" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
getTask: vi.fn().mockResolvedValue({
id: "FN-READY",
title: "Ready Task",
description: "Ready to run",
prompt: "",
status: undefined,
blockedBy: undefined,
comments: [],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(store.clearLastBlockedState).toHaveBeenCalledWith("agent-001");
const promptCall = mockSession.prompt.mock.calls[0]?.[0] as string | undefined;
expect(promptCall).toBeDefined();
expect(promptCall).toContain("FN-DEP-99");
});
});
@@ -1630,7 +1528,7 @@ describe("executeHeartbeat", () => {
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
}
await monitor.stop();
monitor.stop();
});
});
});

View File

@@ -1,23 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
import type { AgentStore, TaskStore } from "@fusion/core";
import { createMockStore, createMockSession } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {

View File

@@ -1,23 +1,10 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, Agent, Message } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {

View File

@@ -1,23 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
import type { AgentStore, TaskStore, Agent } from "@fusion/core";
import { createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {

View File

@@ -87,10 +87,10 @@ describe("createHeartbeatTools", () => {
mockTaskStore = createMockTaskStoreForTools();
});
it("heartbeat task-scoped system prompt documents coding-capable workspace access", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("coding-capable workspace tools");
it("heartbeat task-scoped system prompt documents ambient coordination scope", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_log");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document_write");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("executor");
});
it("heartbeat no-task system prompt documents coding-capable workspace access without task-scoped tools", () => {

View File

@@ -1,23 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent } from "@fusion/core";
import { createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
return {

View File

@@ -191,35 +191,49 @@ function taskRelevanceScore(agent: Agent, task: TaskDetail): number {
/**
* System prompt for heartbeat agent sessions.
* Instructs the agent to perform a single-pass check on its assigned task
* and use `fn_task_create` / `fn_task_log` / `fn_task_document_*` tools to record findings or spawn follow-up work.
* This is an ambient heartbeat: task implementation runs in a separate executor path.
* The heartbeat handles coordination, communication, memory, and routing only.
*/
export const HEARTBEAT_SYSTEM_PROMPT = `You are a heartbeat agent running in a short execution window.
## Your Role
You are a lightweight periodic checker in the broader Fusion system, not the primary implementation agent.
Your purpose is to keep momentum: detect issues early, surface blockers, and route work to the right place.
Think in single-pass interventions, not long coding sessions.
This is an ambient heartbeat. Task implementation work (coding, running tests, making commits) runs in a separate
execution path handled by the executor. Do NOT do task body work or implementation in this heartbeat.
Your purpose is to keep momentum through coordination: surface blockers, respond to messages, manage memory,
delegate, and route work to the right place. Think in single-pass interventions, not coding sessions.
Your job:
1. Check your assigned task — read the description and PROMPT.md if present.
2. Do ONE useful action that changes project clarity or flow.
1. Check your assigned task context — review its state, blockedBy field, and any new comments.
2. Do ONE useful coordination action.
3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts.
4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now.
5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work.
5. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
Examples of ONE useful action:
- DO: summarize a blocker in fn_task_log with concrete next step(s).
**If your bound task is blocked** (blockedBy is set in the task context):
- Surface the blocker concretely with fn_task_log.
- Chase the dependency: comment on the blocking task, send a message to the responsible agent, or ping an owner.
- Look for unblocking work you can spawn or delegate right now.
- Pivot to other relevant coordination work if the blocker cannot be immediately resolved.
**If your bound task is not blocked:**
- Surface progress, status, or coordination needs with fn_task_log or fn_task_document_write.
- Create follow-up tasks for discovered risks or gaps.
- Respond to new steering comments or user messages.
Examples of ONE useful coordination action:
- DO: log a concrete blocker with next steps and message the agent responsible for unblocking.
- DO: create a focused follow-up task when a missing dependency is discovered.
- DO: delegate a well-scoped task to an appropriate idle specialist agent.
- DO: save a short investigation note with fn_task_document_write when the analysis is reusable.
- DON'T: attempt full implementation, broad refactors, or multi-hour coding.
- DON'T: attempt full implementation, run tests, commit code, or do multi-step coding work.
- DON'T: create vague tasks like "investigate stuff" without actionable scope.
Keep work lightweight — this is a single-pass check, not a full implementation run.
You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus fn_task_create, fn_task_log, and fn_task_document tools.
Keep work lightweight — this is a single-pass coordination check, not an implementation run.
You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools,
fn_send_message, fn_read_messages, fn_list_agents, fn_delegate_task, and memory tools.
**Task Documents:** Save important findings with fn_task_document_write(key="...", content="...").
Documents persist across sessions and are visible in the dashboard's Documents tab.
@@ -238,7 +252,8 @@ Prefer fn_delegate_task when immediate ownership by a specific agent materially
## Common Patterns
- **Stuck task:** log the concrete blocker, create a narrowly scoped unblocker task if needed, and optionally message the responsible agent.
- **Blocked task:** log the concrete blocker, chase the dependency via fn_send_message, create a narrowly scoped unblocker task if needed.
- **Stuck task with no blockedBy:** log the observation and create a follow-up task to investigate the root cause.
- **Completed task with follow-up risk:** create explicit follow-up task(s) for residual risk instead of burying notes in a long log.
- **New user/agent comments:** summarize what changed, identify required action, and route via task creation/delegation.
- **Dependency drift:** log the mismatch and create reconciliation tasks with clear dependencies.
@@ -847,6 +862,9 @@ export class HeartbeatMonitor {
// End the heartbeat run tracking
await this.store.endHeartbeatRun(runId, completionResult.status === "completed" ? "completed" : "terminated");
if (completionResult.status === "terminated") {
this.onTerminated?.(agentId, completionResult.stderrExcerpt ?? "Run terminated");
}
this.onRunCompleted?.(agentId, completedRun);
}
@@ -1500,56 +1518,9 @@ export class HeartbeatMonitor {
return (await this.store.getRunDetail(agentId, run.id))!;
}
const blockedBy = typeof liveTaskDetail.blockedBy === "string" ? liveTaskDetail.blockedBy.trim() : "";
const isBlockedTask = liveTaskDetail.status === "queued" && blockedBy.length > 0;
if (isBlockedTask) {
const commentCount = (liveTaskDetail.comments?.length ?? 0) + (liveTaskDetail.steeringComments?.length ?? 0);
const lastCommentId = liveTaskDetail.comments?.at(-1)?.id;
const lastSteeringCommentId = liveTaskDetail.steeringComments?.at(-1)?.id;
const contextHash = Buffer.from(
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
)
.toString("base64")
.slice(0, 16);
const currentBlockedState: BlockedStateSnapshot = {
taskId: resolvedTaskId,
blockedBy,
recordedAt: new Date().toISOString(),
contextHash,
};
const previousBlockedState = await this.store.getLastBlockedState(agentId);
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
await this.completeRun(agentId, run.id, {
status: "completed",
resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId, blockedBy },
});
return (await this.store.getRunDetail(agentId, run.id))!;
}
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
await taskStore.addComment(resolvedTaskId, blockedMessage, "agent", undefined, runContext);
// Audit trail: record comment mutation (FN-1404)
await audit.database({ type: "task:comment:add", target: resolvedTaskId, metadata: { blockedBy } });
await this.store.setLastBlockedState(agentId, currentBlockedState);
heartbeatLog.log(`Task ${resolvedTaskId} is blocked by ${blockedBy} — recorded blocked state`);
await this.completeRun(agentId, run.id, {
status: "completed",
resultJson: { reason: "blocked", taskId: resolvedTaskId, blockedBy },
});
return (await this.store.getRunDetail(agentId, run.id))!;
}
}
}
// Clear blocked state when task is no longer blocked (only for task-scoped runs)
if (!isNoTaskRun) {
await this.store.clearLastBlockedState(agentId);
}
// Track usage via callbacks
const STDOUT_EXCERPT_LIMIT = 4000;
let outputLength = 0;
@@ -2516,11 +2487,13 @@ export class HeartbeatTriggerScheduler {
private updatedListener: ((agent: import("@fusion/core").Agent) => void) | null = null;
private configRevisionListener: ((agentId: string, revision: AgentConfigRevision) => void) | null = null;
private deletedListener: ((agentId: string) => void) | null = null;
private isTaskExecuting?: (taskId: string) => boolean;
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore) {
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore, options?: { isTaskExecuting?: (taskId: string) => boolean }) {
this.store = store;
this.callback = callback;
this.taskStore = taskStore;
this.isTaskExecuting = options?.isTaskExecuting;
}
/**
@@ -2797,7 +2770,7 @@ export class HeartbeatTriggerScheduler {
return;
}
const runtimeConfig = (agent.runtimeConfig ?? {}) as { enabled?: boolean };
const runtimeConfig = (agent.runtimeConfig ?? {}) as { enabled?: boolean; allowParallelExecution?: boolean };
if (runtimeConfig.enabled === false) {
heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (disabled)`);
return;
@@ -2810,6 +2783,12 @@ export class HeartbeatTriggerScheduler {
return;
}
// Guard: when parallel execution is disabled, skip if the bound task is actively executing
if (runtimeConfig.allowParallelExecution === false && this.isTaskExecuting?.(taskId)) {
heartbeatLog.log(`Assignment tick skipped for ${agent.id} (parallel execution disabled, task ${taskId} executing)`);
return;
}
let budgetStatus: AgentBudgetStatus | undefined;
// Budget governance: block even critical triggers when budget is fully exhausted
try {
@@ -3020,6 +2999,13 @@ export class HeartbeatTriggerScheduler {
return;
}
// Guard: when parallel execution is disabled, skip if the agent's bound task is actively executing
const timerRc = (agent.runtimeConfig ?? {}) as { allowParallelExecution?: boolean };
if (timerRc.allowParallelExecution === false && agent.taskId && this.isTaskExecuting?.(agent.taskId)) {
heartbeatLog.log(`Timer tick skipped for ${agentId} (parallel execution disabled, task ${agent.taskId} executing)`);
return;
}
// Global/engine pause guard: scheduler should not dispatch timer callbacks
// while globally paused (hard stop) or engine paused (soft stop for timers).
if (this.taskStore) {

View File

@@ -104,10 +104,9 @@ const COMPLETED_TASK_WATCHDOG_MS = 60_000;
const WORKFLOW_RERUN_WATCHDOG_MS = 15_000;
/**
* @deprecated Kept exported so existing unit tests in executor.test.ts still
* link, but no longer called from the executor. Revision feedback is applied
* as an in-place fix via `reopenLastStepForRevision` — earlier completed
* steps stay done instead of being replayed.
* Determines the step index from which revision should restart given a set of
* completed steps and user feedback. Exported for unit tests; no longer called
* from the executor (revision is now handled via `reopenLastStepForRevision`).
*/
export function determineRevisionResetStart(
steps: ReadonlyArray<{ name: string }>,
@@ -1989,36 +1988,6 @@ export class TaskExecutor {
return "";
}
private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
if (task.dependencies.length === 0) return null;
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (
dep &&
dep.worktree &&
(dep.column === "done" || dep.column === "in-review") &&
existsSync(dep.worktree)
) {
return dep.worktree;
}
}
return null;
}
/**
* Reuse an existing worktree directory from a dependency task.
* Instead of creating a new worktree with `git worktree add`, this creates
* a new branch in the existing worktree via `git checkout -b`. The worktree
* directory (and its build caches) are preserved.
*/
private async reuseWorktree(branch: string, worktreePath: string): Promise<void> {
await execAsync(`git checkout -b "${branch}"`, {
cwd: worktreePath,
});
executorLog.log(`Reused worktree at ${worktreePath}, created branch ${branch}`);
}
/**
* Execute a task in an isolated git worktree.
*
@@ -4623,7 +4592,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
return reRunResult.allPassed;
} finally {
await logger.flush();
await session.dispose();
session.dispose();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -5549,7 +5518,7 @@ and show an appropriate message to the user.\`
* resolved SHA of the dep's tip — that's what gets squash-merged.
*/
private async planSquashImportFromDep(
taskId: string,
_taskId: string,
depTip: string,
originalStartPoint: string | undefined,
): Promise<{ depTip: string; mainBase: string; label: string } | null> {

View File

@@ -484,6 +484,7 @@ export class InProcessRuntime
});
},
this.taskStore,
{ isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId) },
);
this.triggerScheduler.start();