fix: --no-auth override, workflow revision in-place fix, state-driven heartbeats

Three orthogonal fixes bundled together so they re-land as a unit after
earlier worktree-based reverts kept wiping them individually.

1. `--no-auth` flag now actually disables auth. Previously a stale
   FUSION_DAEMON_TOKEN in .env silently re-armed bearer-token auth despite
   the CLI flag. Added a `noAuth` option to ServerOptions; auth-middleware's
   isDaemonAuthActive/getDaemonToken short-circuit to false/undefined when
   set; CLI plumbs opts.noAuth through both createServer call sites.

2. Workflow review failures no longer reset every completed step. Previously
   a single CSS nit from a workflow reviewer could drag 5+ already-approved
   steps back through plan review, code review, and re-execution because
   determineRevisionResetStart fuzzy-matched feedback tokens against step
   names. handleWorkflowRevisionRequest, handleWorkflowStepFailure, and
   sendTaskBackForFix now call a new reopenLastStepForRevision helper that
   flips only the last non-pending step back to pending (with currentStep
   rewind via a newly-accepted updateTask field) — all earlier done steps
   stay done, and the agent applies the feedback as an in-place patch per
   the updated PROMPT.md instructions. determineRevisionResetStart stays
   exported as @deprecated so existing unit tests still link.

3. Heartbeat scheduling is now state-driven. Previously a non-ephemeral
   agent with a stale runtimeConfig.enabled=false on disk would never tick
   and the Pause/Resume button couldn't arm the timer without also flipping
   that hidden flag. HeartbeatTriggerScheduler's watchAgentLifecycle now
   registers on transitions into active/running and clears on transitions
   out; the tick and assignment-trigger guards key off state + ephemeral
   classification. InProcessRuntime's created/updated listeners and startup
   scan mirror the same semantics. runtimeConfig.enabled is only retained
   for ephemeral (task-worker) opt-out.

Tests updated: agent-heartbeat.test.ts — one test renamed from "skips
registration when enabled is false" (obsolete behavior) to
"registers regardless of the legacy enabled flag"; 4 assignment-watching
tests now pass a realistic `state: "active"` on mock agents. 207 heartbeat
tests + 330 executor tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-22 22:12:19 -07:00
parent 3b212b9286
commit 22d31c4cac
8 changed files with 173 additions and 134 deletions

View File

@@ -1,7 +1,7 @@
import type { AddressInfo } from "node:net";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths } from "@fusion/core";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
@@ -628,6 +628,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
});
const shutdown = async (signal: NodeJS.Signals) => {
@@ -761,14 +762,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const agents = await agentStore.listAgents();
for (const agent of agents) {
// State drives whether a timer is armed. Arm timers only for
// non-ephemeral agents currently in active/running — transitions
// after startup are handled by the scheduler's agent:updated listener.
if (isEphemeralAgent(agent)) continue;
if (agent.state !== "active" && agent.state !== "running") continue;
const rc = agent.runtimeConfig;
if (rc && (rc.heartbeatIntervalMs || rc.enabled !== undefined || rc.maxConcurrentRuns)) {
triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc.heartbeatIntervalMs as number | undefined,
enabled: rc.enabled as boolean | undefined,
maxConcurrentRuns: rc.maxConcurrentRuns as number | undefined,
});
}
triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
}
if (agents.length > 0) {
console.log(`[engine] Registered ${triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`);
@@ -810,6 +813,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
});
}

View File

@@ -2215,7 +2215,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2262,6 +2262,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
if (updates.steps !== undefined) task.steps = updates.steps;
if (updates.currentStep !== undefined) task.currentStep = updates.currentStep;
if (updates.status === null) {
task.status = undefined;
} else if (updates.status !== undefined) {

View File

@@ -33,13 +33,15 @@ function isApiPath(path: string): boolean {
/**
* Check if daemon auth should be active.
* Auth is enabled when FUSION_DAEMON_TOKEN env var is set OR daemon options are provided.
* Always returns false when options.noAuth is true (CLI --no-auth override).
*/
export function isDaemonAuthActive(options?: { daemon?: { token: string } }): boolean {
// Check explicit daemon option
export function isDaemonAuthActive(options?: { daemon?: { token: string }; noAuth?: boolean }): boolean {
if (options?.noAuth) {
return false;
}
if (options?.daemon?.token) {
return true;
}
// Check environment variable
if (process.env.FUSION_DAEMON_TOKEN) {
return true;
}
@@ -48,8 +50,12 @@ export function isDaemonAuthActive(options?: { daemon?: { token: string } }): bo
/**
* Get the daemon token from options or environment.
* Returns undefined when options.noAuth is true, regardless of env.
*/
export function getDaemonToken(options?: { daemon?: { token: string } }): string | undefined {
export function getDaemonToken(options?: { daemon?: { token: string }; noAuth?: boolean }): string | undefined {
if (options?.noAuth) {
return undefined;
}
if (options?.daemon?.token) {
return options.daemon.token;
}

View File

@@ -204,6 +204,10 @@ export interface ServerOptions {
/** Daemon mode configuration with bearer token authentication.
* When provided, all API requests (except /api/health) require valid bearer token. */
daemon?: { token: string };
/** Explicitly disable bearer-token auth, ignoring FUSION_DAEMON_TOKEN /
* FUSION_DASHBOARD_TOKEN env vars. Used by `fn dashboard --no-auth` so a
* stale token in a project .env doesn't silently override the flag. */
noAuth?: boolean;
/** Optional TLS credentials. When provided, the server is served over HTTP/2
* with HTTP/1.1 fallback (allowHTTP1:true) — this lifts the browser's
* per-origin connection cap so long-lived SSE streams no longer starve
@@ -398,7 +402,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// that then captures ?token= from the URL and injects a Bearer header on every
// /api/* call. WebSocket upgrades are gated separately in setupTerminalWebSocket /
// setupBadgeWebSocket.
const daemonToken = options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
const daemonToken = options?.noAuth
? undefined
: options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
if (daemonToken) {
app.use(createAuthMiddleware(daemonToken));
}

View File

@@ -4118,9 +4118,13 @@ describe("HeartbeatTriggerScheduler", () => {
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("skips registration when enabled is false", () => {
it("registers regardless of the legacy enabled flag (state is the source of truth)", () => {
// runtimeConfig.enabled is no longer honored by the scheduler — pause
// and resume happen through agent.state, and the agent:updated listener
// drives register/unregister. Callers that still pass `enabled: false`
// should not silently lose the timer.
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000, enabled: false });
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("applies default 3600-second interval when intervalMs is undefined", async () => {
@@ -4486,7 +4490,7 @@ describe("HeartbeatTriggerScheduler", () => {
});
it("triggers callback on agent:assigned event", async () => {
const agent = { id: "agent-test", name: "Test", taskId: "FN-001" } as import("@fusion/core").Agent;
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {}, taskId: "FN-001" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-001");
@@ -4578,7 +4582,7 @@ describe("HeartbeatTriggerScheduler", () => {
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -4601,7 +4605,7 @@ describe("HeartbeatTriggerScheduler", () => {
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-005");
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -4636,7 +4640,7 @@ describe("HeartbeatTriggerScheduler", () => {
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback, assignmentTaskStore);
scheduler.start();
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-006");
await vi.waitFor(() => {

View File

@@ -1664,26 +1664,35 @@ interface AgentTimer {
handle: ReturnType<typeof setInterval>;
}
/**
* True when an agent's state indicates it should be ticking right now.
* Heartbeats track liveness while the agent is meant to be doing work.
*/
function isTickableState(state: Agent["state"]): boolean {
return state === "active" || state === "running";
}
/**
* True when the scheduler should manage this agent at all. Ephemeral
* (task-worker) agents are driven directly by TaskExecutor and must never
* acquire a scheduler timer.
*/
function isHeartbeatManaged(agent: Agent): boolean {
return !isEphemeralAgent(agent);
}
/**
* HeartbeatTriggerScheduler manages timer-based heartbeat triggers for agents.
*
* Each agent can be registered with a heartbeat config that specifies
* the timer interval. When the timer fires, the scheduler invokes the
* provided callback with the appropriate source and context.
* State is the source of truth: state ∈ {active, running} on a non-ephemeral
* agent arms the timer; any other state or any ephemeral agent doesn't. The
* `runtimeConfig.enabled` flag is no longer consulted here — pause/resume
* happens through `agent.state`, and the `agent:updated` listener arms or
* clears the timer on transitions.
*
* The scheduler respects:
* - `enabled`: Skip registration if false
* - `heartbeatIntervalMs`: Timer interval (undefined = no timer)
* Other config knobs still apply:
* - `heartbeatIntervalMs`: Timer interval (default 1h)
* - `maxConcurrentRuns`: Skip tick if agent already has an active run
*
* Usage:
* ```typescript
* const scheduler = new HeartbeatTriggerScheduler(agentStore, async (agentId, source, ctx) => {
* await heartbeatMonitor.startRun(agentId, { source, triggerDetail: ctx.triggerDetail, contextSnapshot: { ...ctx } });
* });
* scheduler.registerAgent("agent-123", { heartbeatIntervalMs: 3600000, enabled: true });
* scheduler.start();
* ```
*/
export class HeartbeatTriggerScheduler {
private store: AgentStore;
@@ -1751,11 +1760,9 @@ export class HeartbeatTriggerScheduler {
* @param config - Per-agent heartbeat config
*/
registerAgent(agentId: string, config: AgentHeartbeatConfig): void {
// Skip if not enabled
if (config.enabled === false) {
heartbeatLog.log(`Skipping timer registration for ${agentId} (disabled)`);
return;
}
// State drives whether an agent ticks; this method no longer honors
// `config.enabled` as a registration gate. Callers filter based on
// state + ephemeral classification before calling through.
// Apply default interval if not explicitly configured
// This ensures agents with heartbeat monitoring enabled but no explicit interval
@@ -1884,8 +1891,8 @@ export class HeartbeatTriggerScheduler {
if (!this.running) return;
try {
if (agent.runtimeConfig?.enabled === false) {
heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (heartbeat disabled)`);
if (!isHeartbeatManaged(agent) || !isTickableState(agent.state)) {
heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (state=${agent.state})`);
return;
}
@@ -1966,10 +1973,29 @@ export class HeartbeatTriggerScheduler {
private watchAgentLifecycle(): void {
if (this.updatedListener || this.deletedListener) return;
// State-driven registration: when an agent transitions into a tickable
// state (active/running) arm the timer; transitioning out clears it.
this.updatedListener = (agent) => {
if (agent.state === "terminated" || agent.runtimeConfig?.enabled === false) {
if (!isHeartbeatManaged(agent) || !isTickableState(agent.state)) {
this.unregisterAgent(agent.id);
return;
}
if (this.timers.has(agent.id)) {
// Already ticking — re-registering would reset the interval mid-cycle
// on every unrelated agent update.
return;
}
const rc = (agent.runtimeConfig ?? {}) as {
heartbeatIntervalMs?: number;
maxConcurrentRuns?: number;
};
this.registerAgent(agent.id, {
heartbeatIntervalMs: rc.heartbeatIntervalMs,
maxConcurrentRuns: rc.maxConcurrentRuns,
});
heartbeatLog.log(
`State-driven registration: ${agent.id} is ${agent.state} — timer armed`,
);
};
this.deletedListener = (agentId) => {
this.unregisterAgent(agentId);
@@ -2004,8 +2030,8 @@ export class HeartbeatTriggerScheduler {
this.unregisterAgent(agentId);
return;
}
if (agent.state === "terminated" || agent.runtimeConfig?.enabled === false) {
heartbeatLog.log(`Timer tick skipped for ${agentId} (disabled or terminated)`);
if (!isHeartbeatManaged(agent) || !isTickableState(agent.state)) {
heartbeatLog.log(`Timer tick skipped for ${agentId} (state=${agent.state})`);
this.unregisterAgent(agentId);
return;
}

View File

@@ -75,16 +75,10 @@ const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"
const MAX_WORKFLOW_STEP_RETRIES = 3;
/**
* Decide the earliest step index that must be reset when a workflow reviewer
* (e.g. Frontend UX Design) requests revision. Two invariants:
* - Preflight (step 0, when named "preflight") is never reset — redoing it
* is pure waste because it only reads context, and revision feedback
* never targets it. This alone saves 1015 min per revision.
* - If the feedback text mentions a distinctive (>=5-char) token from a
* step's name, the reset starts at that step; everything before it stays
* `done`. This avoids dragging already-approved earlier steps through
* plan-review / code-review again.
* Returns an index >= steps.length when there is nothing to reset.
* @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.
*/
export function determineRevisionResetStart(
steps: ReadonlyArray<{ name: string }>,
@@ -2870,13 +2864,11 @@ export class TaskExecutor {
/**
* Handle a workflow step revision request.
*
* This method:
* 1. Updates PROMPT.md with "Workflow Revision Instructions" section
* 2. Resets task execution state (all steps reset to pending)
* 3. Schedules fresh execution to run after current guard unwinds
*
* The task stays in "in-progress" and is scheduled for a fresh executor pass.
*
* Re-opens ONLY the last step so the executor has exactly one pending slot
* to re-enter through. All earlier done steps stay done — the agent reads
* the injected feedback from PROMPT.md and applies an in-place fix rather
* than redoing any completed step.
*/
private async handleWorkflowRevisionRequest(
task: Task,
@@ -2886,39 +2878,20 @@ export class TaskExecutor {
): Promise<void> {
executorLog.log(`${task.id}: workflow revision requested by step "${stepName}"`);
// Scope the reset: keep Preflight intact and, when feedback mentions a
// specific step, only reset from that step onward. Earlier already-reviewed
// steps stay done, avoiding redundant plan/code-review round-trips.
const updatedTask = await this.store.getTask(task.id);
const resetStart = determineRevisionResetStart(updatedTask.steps, feedback);
const targetStepName =
resetStart < updatedTask.steps.length ? updatedTask.steps[resetStart].name : null;
const resetSummary =
resetStart >= updatedTask.steps.length
? "no steps to reset"
: `resetting steps ${resetStart + 1}${updatedTask.steps.length} (starting at "${targetStepName}")`;
const reopen = await this.reopenLastStepForRevision(task.id, updatedTask);
const reopenSummary = reopen
? `re-opening Step ${reopen.index + 1} ("${reopen.name}") for in-place fix`
: "no step to re-open (none were completed)";
await this.store.logEntry(
task.id,
`Workflow step "${stepName}" requested revision — ${resetSummary}`,
`Workflow step "${stepName}" requested revision — ${reopenSummary}`,
feedback,
);
// 1. Update PROMPT.md with revision instructions
await this.injectWorkflowRevisionInstructions(task, feedback, {
resetStart,
targetStepName,
totalSteps: updatedTask.steps.length,
});
await this.injectWorkflowRevisionInstructions(task, feedback);
// 2. Reset the scoped range of steps to pending
for (let i = resetStart; i < updatedTask.steps.length; i++) {
if (updatedTask.steps[i].status !== "pending") {
await this.store.updateStep(task.id, i, "pending");
}
}
// 3. Clear any session file so we get a fresh session
await this.store.updateTask(task.id, {
status: null,
sessionFile: null,
@@ -2953,6 +2926,37 @@ export class TaskExecutor {
}, 0);
}
/**
* Re-open the last non-pending step so a revision/failure handler gives the
* executor exactly one pending slot to re-enter through. Returns the index
* and name of the step that was flipped to `pending`, or null when there
* was nothing to re-open.
*/
private async reopenLastStepForRevision(
taskId: string,
task: Task,
): Promise<{ index: number; name: string } | null> {
const steps = task.steps;
if (steps.length === 0) return null;
let targetIndex = -1;
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status !== "pending") {
targetIndex = i;
break;
}
}
if (targetIndex === -1) {
await this.store.updateTask(taskId, { currentStep: 0 });
return null;
}
await this.store.updateStep(taskId, targetIndex, "pending");
await this.store.updateTask(taskId, { currentStep: targetIndex });
return { index: targetIndex, name: steps[targetIndex].name };
}
/**
* Inject or update the "Workflow Revision Instructions" section in PROMPT.md.
* This section contains feedback from workflow steps that requested revisions.
@@ -2961,7 +2965,6 @@ export class TaskExecutor {
private async injectWorkflowRevisionInstructions(
task: Task,
feedback: string,
scope?: { resetStart: number; targetStepName: string | null; totalSteps: number },
): Promise<void> {
const promptPath = join(this.store.getFusionDir(), "tasks", task.id, "PROMPT.md");
@@ -2974,17 +2977,9 @@ export class TaskExecutor {
return;
}
// Describe which steps were reset so the executor knows not to re-run
// Preflight or any earlier already-approved steps. When no scope is
// provided (legacy callers), fall back to the previous "all steps" wording.
let scopeLine: string;
if (scope && scope.targetStepName && scope.resetStart < scope.totalSteps) {
scopeLine = `Re-execution starts at **Step ${scope.resetStart + 1} ("${scope.targetStepName}")**. Earlier steps remain done — do not re-run them unless the feedback explicitly calls them out.`;
} else if (scope && scope.resetStart >= scope.totalSteps) {
scopeLine = "No steps were reset; apply the feedback as an in-place fix and call task_done() when complete.";
} else {
scopeLine = "Address the feedback above by making the necessary code changes, then mark all affected steps as done and call task_done() when complete.";
}
// All prior steps stay done — agent applies the feedback as an in-place
// patch rather than re-planning or re-executing earlier steps.
const scopeLine = "All prior steps remain **done**. Apply the feedback above as an in-place fix (make the necessary code changes, commit, and call `task_done()` when complete). Do **not** re-run or re-plan any earlier step unless the feedback explicitly calls it out.";
// Check for existing Workflow Revision Instructions section
const revisionSectionHeader = "## Workflow Revision Instructions";
@@ -3065,13 +3060,10 @@ ${feedback}
// 2. Inject failure feedback into PROMPT.md
await this.injectWorkflowStepFailureInstructions(task, failureFeedback, stepName, retryCount);
// 3. Reset all steps to pending for fresh execution
// 3. Re-open only the last step so the executor has a single pending
// slot to re-enter. Earlier done steps stay done.
const updatedTask = await this.store.getTask(task.id);
for (let i = 0; i < updatedTask.steps.length; i++) {
if (updatedTask.steps[i].status !== "pending") {
await this.store.updateStep(task.id, i, "pending");
}
}
await this.reopenLastStepForRevision(task.id, updatedTask);
// 4. Clear any session file so we get a fresh session
await this.store.updateTask(task.id, {
@@ -3138,13 +3130,10 @@ ${feedback}
// Pass MAX_WORKFLOW_STEP_RETRIES to indicate retries are exhausted (shows "3/3 (0 remaining)")
await this.injectWorkflowStepFailureInstructions(task, failureFeedback, stepName, MAX_WORKFLOW_STEP_RETRIES);
// 4. Reset all steps to pending
// 4. Re-open only the last step for a single in-place fix pass. Earlier
// done steps stay done so the executor doesn't redo finished work.
const updatedTask = await this.store.getTask(taskId);
for (let i = 0; i < updatedTask.steps.length; i++) {
if (updatedTask.steps[i].status !== "pending") {
await this.store.updateStep(taskId, i, "pending");
}
}
await this.reopenLastStepForRevision(taskId, updatedTask);
// 5. Clear error/status/session fields and reset workflow step retries
await this.store.updateTask(taskId, {

View File

@@ -492,14 +492,18 @@ export class InProcessRuntime
);
this.triggerScheduler.start();
// Set up dynamic registration for agents created or updated after startup
// Dynamic registration follows state, not runtimeConfig.enabled.
// Active/running → timer armed; anything else → not. Ephemeral
// (task-worker) agents are never armed.
const isTickable = (agent: import("@fusion/core").Agent) =>
!isEphemeralAgent(agent) && (agent.state === "active" || agent.state === "running");
this.agentCreatedListener = (agent) => {
if (!this.triggerScheduler) return;
if (!isTickable(agent)) return;
const rc = agent.runtimeConfig;
if (rc?.enabled === false) return;
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
enabled: rc?.enabled as boolean | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
runtimeLog.log(`Registered new agent ${agent.id} for heartbeat triggers`);
@@ -508,18 +512,17 @@ export class InProcessRuntime
this.agentUpdatedListener = (agent) => {
if (!this.triggerScheduler) return;
const rc = agent.runtimeConfig;
if (rc?.enabled === false) {
if (!isTickable(agent)) {
this.triggerScheduler.unregisterAgent(agent.id);
runtimeLog.log(`Unregistered agent ${agent.id} from heartbeat triggers (disabled)`);
} else {
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
enabled: rc?.enabled as boolean | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
runtimeLog.log(`Re-registered agent ${agent.id} for heartbeat triggers`);
runtimeLog.log(`Unregistered agent ${agent.id} from heartbeat triggers (state=${agent.state})`);
return;
}
const rc = agent.runtimeConfig;
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
runtimeLog.log(`Re-registered agent ${agent.id} for heartbeat triggers (state=${agent.state})`);
};
this.agentStore.on("agent:updated", this.agentUpdatedListener);
@@ -563,21 +566,21 @@ export class InProcessRuntime
};
this.agentStore.on("agent:stateChanged", this.ephemeralTerminationListener);
// Register existing agents with heartbeat monitoring not explicitly disabled
// Agents without explicit heartbeat config will use the default 3600-second interval (1 hour)
// Register existing agents whose current state is tickable. Agents
// in paused/idle/error/terminated stay unregistered until the user
// re-activates them; the agentUpdatedListener arms the timer the
// moment state transitions to active.
try {
const agents = await this.agentStore.listAgents();
let registeredCount = 0;
for (const agent of agents) {
if (!isTickable(agent)) continue;
const rc = agent.runtimeConfig;
if (rc?.enabled !== false) {
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
enabled: rc?.enabled as boolean | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
registeredCount++;
}
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
registeredCount++;
}
if (agents.length > 0) {
runtimeLog.log(`Registered ${registeredCount} of ${agents.length} agents for heartbeat triggers`);