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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 10–15 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, {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user