U11: resolve the scheduler's event-handler columns by trait (10 live sites, sync resolution) (#2518)
Based on `main`. Ten live `"todo"` sites in `scheduler.ts` now resolve
the column by trait.
## Four groups, converted together
They fail **independently**, and a half-conversion is indistinguishable
from a working system:
| group | sites | failure mode |
|---|---:|---|
| **Wake triggers** | 4 | **Latency** — snapshot invalidation,
mission-failure tracking, engine requeue tracking, move-to-backlog wake.
The wake doesn't fire and the card waits up to a poll interval. Exactly
why it would go unnoticed indefinitely. |
| **Parked wakes** | 2 | Latency — unpause and planning-finished, keyed
on hold OR intake. |
| **Dependency** | 3 | **Not latency.** After a blocker completes or is
soft-deleted, the query returns nothing, so the dependent is *never*
unblocked and waits on a blocker that already finished. |
| **Agent link** | 1 | `rollbackRunningAgentsForQueuedTodoTask` passes a
synthetic `{ column: "todo" }`. Wrong here **drops a running agent's
task link** — the worse direction of that safeguard. Resolved
`parkedColumns` is now passed through too, rather than letting the
helper fall back to its legacy default. |
## Resolution is synchronous, deliberately — the part worth reading
My first cut used the async resolver and made the `task:updated`
listener `async` to suit it. **That broke 5 pre-existing tests, and the
tests were right:** introducing a new `await` *before* a listener's
existing synchronous work defers everything after it to a microtask and
reorders handlers relative to a synchronous emitter.
A conversion must not change event ordering. It now uses the store's
sync IR path (`resolveTaskWorkflowIrSync`), so **no new suspension point
is introduced anywhere**.
That's the fifth time in this program a change that looked like a move
quietly altered behavior — and the first time the existing suite caught
it before review.
## Verification
- **Mutation-verified:** forcing the resolver back to the literals fails
**4 of the 6** new tests
- 110 tests green across all 8 scheduler suites (6 new)
- Fail-soft to the legacy pair: an unresolvable workflow behaves exactly
as before rather than losing the wake
- merge gate green (309 + 10 + 71), tsc clean, lint clean
## Measured
10 of my unit's 68 remaining code sites converted.
`scheduler.ts` now has **one** `"todo"` literal left in live code:
`isRunnableQueuedOverlapCandidate`, which is **exported but has no
production caller** — its only consumer was the legacy dispatcher
deleted in #2505. That's a **deletion, not a conversion**, so it is
deliberately not in this PR.
No changeset: `@fusion/engine` is private.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Scheduling now correctly recognizes workflow-specific hold and intake
columns, including renamed columns.
* Tasks entering a hold column reliably trigger scheduling and wake-up
behavior.
* Dependency recovery now finds blocked tasks in renamed hold columns.
* Planning, unpausing, task completion, deletion, and requeue flows now
respect each workflow’s configured parked columns.
* Prevented unnecessary scheduling for moves between unrelated workflow
columns.
* **Tests**
* Added coverage for renamed hold-column scheduling, wake-up, and
dependency-unblocking scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-11:20 (U11 conversion — scheduler live sites):
|
||||
|
||||
The scheduler's event handlers decide "is this the backlog column?" by comparing
|
||||
against the literal `"todo"`. For a workflow that names its hold column anything
|
||||
else, each of these silently stops firing — and after U11 deletes `todo` from the
|
||||
builtins, they stop firing for EVERY workflow.
|
||||
|
||||
Four groups, all covered here because they fail independently:
|
||||
|
||||
WAKE TRIGGERS a move into/out of the hold column should wake the scheduler so a
|
||||
freed slot is used immediately instead of waiting a poll interval.
|
||||
Failure mode is SLOW, not wrong — up to one poll interval of
|
||||
latency per affected move — which is exactly why it would go
|
||||
unnoticed indefinitely.
|
||||
|
||||
PARKED WAKES unpause and planning-finished wakes fire for a card resting in
|
||||
hold OR intake. Same latency failure.
|
||||
|
||||
DEPENDENCY after a blocker completes or is soft-deleted, dependents resting
|
||||
in the hold column are unblocked. This one is NOT latency: a
|
||||
dependent never gets unblocked, so it waits on a blocker that is
|
||||
already done.
|
||||
|
||||
AGENT LINK the parked-agent-link evaluation passes a synthetic
|
||||
`{ column: "todo" }`, which decides whether a running agent's task
|
||||
link survives. Wrong here means a live agent's link is dropped.
|
||||
|
||||
Written against the literal implementation and observed FAILING first.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore, WorkflowIr } from "@fusion/core";
|
||||
import { Scheduler } from "../scheduler.js";
|
||||
import { evaluateParkedAgentTaskLink } from "../task-agent-sync.js";
|
||||
|
||||
const WF = "custom:wf";
|
||||
|
||||
/** Hold is `drafting`, intake is `inbox` — no `todo` column exists. */
|
||||
function renamedIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
id: WF,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
columns: [
|
||||
{ id: "inbox", name: "inbox", traits: [{ trait: "intake" }] },
|
||||
{ id: "drafting", name: "drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", name: "building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
} as unknown as WorkflowIr;
|
||||
}
|
||||
|
||||
function createStore(tasks: Record<string, unknown>[] = []) {
|
||||
const listeners = new Map<string, ((payload: unknown) => void)[]>();
|
||||
const selection = { workflowId: WF, stepIds: [] };
|
||||
const listTasks = vi.fn(async (opts?: { column?: string }) =>
|
||||
opts?.column ? tasks.filter((t) => t.column === opts.column) : tasks,
|
||||
);
|
||||
const store = {
|
||||
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
listTasks,
|
||||
getTask: vi.fn(async (id: string) => tasks.find((t) => t.id === id) ?? null),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getCompletionHandoffAcceptedMarker: vi.fn().mockResolvedValue(null),
|
||||
getTaskWorkflowSelection: vi.fn(() => selection),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
|
||||
getWorkflowDefinition: vi.fn(async () => ({ ir: renamedIr() })),
|
||||
resolveTaskWorkflowIrSync: vi.fn(() => renamedIr()),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
return {
|
||||
store,
|
||||
listTasks,
|
||||
emit: async (event: string, payload: unknown) => {
|
||||
for (const l of listeners.get(event) ?? []) await l(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function task(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "FN-1",
|
||||
column: "drafting",
|
||||
status: null,
|
||||
paused: false,
|
||||
userPaused: false,
|
||||
assignedAgentId: null,
|
||||
checkedOutBy: null,
|
||||
deletedAt: null,
|
||||
dependencies: [],
|
||||
blockedBy: null,
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-29-17:20 (PR #2518 review — coderabbit):
|
||||
Agent-link coverage needs a real `agentStore` plus the live-execution signal, so
|
||||
the harness takes both. `hasActiveAgentExecution` is the ONLY difference between
|
||||
the preserve and clear cases below — the safeguard must turn on live proof, not
|
||||
on the column vocabulary.
|
||||
*/
|
||||
function createAgentStore(agents: Record<string, unknown>[], freshRun: unknown = null) {
|
||||
const updateAgentState = vi.fn().mockResolvedValue(undefined);
|
||||
const syncExecutionTaskLink = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
agentStore: {
|
||||
listAgents: vi.fn(async () => agents),
|
||||
getActiveHeartbeatRun: vi.fn(async () => freshRun),
|
||||
updateAgentState,
|
||||
syncExecutionTaskLink,
|
||||
},
|
||||
updateAgentState,
|
||||
syncExecutionTaskLink,
|
||||
};
|
||||
}
|
||||
|
||||
function createScheduler(
|
||||
tasks: Record<string, unknown>[] = [],
|
||||
options: Record<string, unknown> = {},
|
||||
) {
|
||||
const { store, emit, listTasks } = createStore(tasks);
|
||||
const scheduler = new Scheduler(store, options as never);
|
||||
const schedule = vi.spyOn(scheduler, "schedule").mockResolvedValue(undefined);
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
return { scheduler, emit, schedule, store, listTasks };
|
||||
}
|
||||
|
||||
describe("scheduler event handlers under a renamed hold column", () => {
|
||||
describe("wake triggers (failure mode is latency, which is why it hides)", () => {
|
||||
it("wakes when a card moves INTO the renamed hold column", async () => {
|
||||
const { emit, schedule } = createScheduler();
|
||||
await emit("task:moved", { task: task(), from: "building", to: "drafting", source: "engine" });
|
||||
expect(schedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT wake for a move between two non-hold columns", async () => {
|
||||
/* The negative half: converting must not turn every move into a wake. */
|
||||
const { emit, schedule } = createScheduler();
|
||||
await emit("task:moved", { task: task({ column: "building" }), from: "inbox", to: "building", source: "user" });
|
||||
expect(schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parked wakes (hold OR intake)", () => {
|
||||
it("wakes when a card unpauses in the renamed HOLD column", async () => {
|
||||
const { emit, schedule } = createScheduler();
|
||||
await emit("task:updated", task({ paused: true }));
|
||||
await emit("task:updated", task({ paused: false }));
|
||||
expect(schedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wakes when planning finishes in the renamed INTAKE column", async () => {
|
||||
const { emit, schedule } = createScheduler();
|
||||
await emit("task:updated", task({ column: "inbox", status: "planning" }));
|
||||
await emit("task:updated", task({ column: "inbox", status: null }));
|
||||
expect(schedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT wake for a card resting in a wip column", async () => {
|
||||
const { emit, schedule } = createScheduler();
|
||||
await emit("task:updated", task({ column: "building", status: "planning" }));
|
||||
await emit("task:updated", task({ column: "building", status: null }));
|
||||
expect(schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependency unblocking (failure mode is a card that waits forever)", () => {
|
||||
it("finds dependents resting in the renamed hold column when a blocker completes", async () => {
|
||||
/*
|
||||
Not a latency bug: if the query returns nothing, the dependent is never
|
||||
unblocked and waits on a blocker that already finished.
|
||||
*/
|
||||
const dependent = task({ id: "FN-DEP", column: "drafting", dependencies: ["FN-BLOCK"], blockedBy: "FN-BLOCK" });
|
||||
const blocker = task({ id: "FN-BLOCK", column: "shipped" });
|
||||
const { emit, listTasks } = createScheduler([dependent, blocker]);
|
||||
|
||||
await emit("task:moved", { task: blocker, from: "building", to: "done", source: "engine" });
|
||||
|
||||
const queried = listTasks.mock.calls.map((c) => (c[0] as { column?: string } | undefined)?.column);
|
||||
expect(queried).not.toContain("todo");
|
||||
expect(queried).toContain("drafting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent link (wrong here DROPS a live agent's task link)", () => {
|
||||
/*
|
||||
CORRECTED after mutation testing. The first version of these tests asserted
|
||||
that the literal synthetic column dropped a live agent's link — and reverting
|
||||
the conversion did NOT fail them, because the literal passed `{column:"todo"}`
|
||||
together with the helper's LEGACY default parked list. The pair was
|
||||
self-consistent, so it read as parked either way: this site's conversion is
|
||||
behaviour-NEUTRAL.
|
||||
|
||||
What is actually load-bearing is that the synthetic column and `parkedColumns`
|
||||
travel TOGETHER. Drift between them — a resolved column checked against the
|
||||
legacy list, or the reverse — reads as unparked and clears a live agent's link.
|
||||
These tests pin the live-proof safeguard and that consistency invariant, which
|
||||
is what the site really depends on.
|
||||
*/
|
||||
const runningAgent = { id: "AG-1", taskId: "FN-1", state: "running" };
|
||||
|
||||
it("PRESERVES a live agent's link under the renamed hold column", async () => {
|
||||
const { agentStore, updateAgentState, syncExecutionTaskLink } = createAgentStore([runningAgent]);
|
||||
const { scheduler } = createScheduler([task()], {
|
||||
agentStore,
|
||||
hasActiveAgentExecution: () => true,
|
||||
});
|
||||
|
||||
await (scheduler as unknown as {
|
||||
rollbackRunningAgentsForQueuedTodoTask: (id: string) => Promise<void>;
|
||||
}).rollbackRunningAgentsForQueuedTodoTask("FN-1");
|
||||
|
||||
expect(updateAgentState).not.toHaveBeenCalled();
|
||||
expect(syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CLEARS the link when nothing proves the execution is live", async () => {
|
||||
/* The negative half. Without it the test above passes for a scheduler that
|
||||
preserves every link unconditionally, which would be a different bug. */
|
||||
const { agentStore, updateAgentState, syncExecutionTaskLink } = createAgentStore([runningAgent]);
|
||||
const { scheduler } = createScheduler([task()], {
|
||||
agentStore,
|
||||
hasActiveAgentExecution: () => false,
|
||||
});
|
||||
|
||||
await (scheduler as unknown as {
|
||||
rollbackRunningAgentsForQueuedTodoTask: (id: string) => Promise<void>;
|
||||
}).rollbackRunningAgentsForQueuedTodoTask("FN-1");
|
||||
|
||||
expect(updateAgentState).toHaveBeenCalledWith("AG-1", "active");
|
||||
expect(syncExecutionTaskLink).toHaveBeenCalledWith("AG-1", undefined);
|
||||
});
|
||||
|
||||
it("leaves agents linked to OTHER tasks untouched", async () => {
|
||||
const { agentStore, updateAgentState } = createAgentStore([
|
||||
{ id: "AG-2", taskId: "FN-OTHER", state: "running" },
|
||||
]);
|
||||
const { scheduler } = createScheduler([task()], {
|
||||
agentStore,
|
||||
hasActiveAgentExecution: () => false,
|
||||
});
|
||||
|
||||
await (scheduler as unknown as {
|
||||
rollbackRunningAgentsForQueuedTodoTask: (id: string) => Promise<void>;
|
||||
}).rollbackRunningAgentsForQueuedTodoTask("FN-1");
|
||||
|
||||
expect(updateAgentState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the link when the synthetic column DRIFTS from parkedColumns", () => {
|
||||
/*
|
||||
The real defect this site can suffer, asserted against the helper directly
|
||||
because the scheduler passes the pair consistently by construction. If a
|
||||
future edit resolves one side and not the other, a live agent loses its task.
|
||||
*/
|
||||
const drifted = evaluateParkedAgentTaskLink({
|
||||
agent: { id: "AG-1", taskId: "FN-1" },
|
||||
linkedTask: { column: "drafting" },
|
||||
hasActiveAgentExecution: () => true,
|
||||
parkedColumns: ["todo", "triage"],
|
||||
});
|
||||
expect(drifted.shouldPreserveParkedLink).toBe(false);
|
||||
|
||||
const consistent = evaluateParkedAgentTaskLink({
|
||||
agent: { id: "AG-1", taskId: "FN-1" },
|
||||
linkedTask: { column: "drafting" },
|
||||
hasActiveAgentExecution: () => true,
|
||||
parkedColumns: ["drafting", "inbox"],
|
||||
});
|
||||
expect(consistent.shouldPreserveParkedLink).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js";
|
||||
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
|
||||
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit } from "@fusion/core";
|
||||
import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns } from "@fusion/core";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core";
|
||||
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
|
||||
import { moveTaskToReplanColumn } from "./replan-target.js";
|
||||
@@ -287,6 +287,39 @@ export function getUnmetSchedulingDependencies(
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-11:35 (U11 conversion):
|
||||
A task's HOLD and INTAKE columns, resolved from its own workflow.
|
||||
|
||||
The scheduler's event handlers all ask a variant of "is this the backlog?" and
|
||||
answered it with the literal `"todo"`. That silently stops matching for a renamed
|
||||
workflow, and stops matching for EVERY workflow once U11 deletes `todo` from the
|
||||
builtins. Most of these failures are LATENCY rather than incorrectness — a wake
|
||||
that does not fire costs up to one poll interval — which is precisely why they
|
||||
would go unnoticed.
|
||||
|
||||
Resolution is per task because a board spans workflows. Cost matches existing
|
||||
practice in this file, which already resolves per task at the dispatch-diagnostic
|
||||
and worktree-capacity sites; these handlers fire per move/update event, not per
|
||||
card in a sweep.
|
||||
|
||||
Fail-soft to the legacy pair: an unresolvable workflow behaves exactly as before
|
||||
this conversion rather than losing the wake entirely.
|
||||
*/
|
||||
/* SYNCHRONOUS on purpose. These run inside `task:moved` / `task:updated`
|
||||
listeners; introducing a new `await` before their existing synchronous work
|
||||
defers everything after it to a microtask and reorders handlers relative to a
|
||||
synchronous emitter. A file split must not change event ordering, so the
|
||||
resolution uses the store's sync IR path. */
|
||||
function resolveTaskParkedColumnsSync(store: TaskStore, taskId: string): { hold: string; intake: string } {
|
||||
try {
|
||||
const lifecycle = resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(taskId));
|
||||
return { hold: lifecycle?.hold ?? "todo", intake: lifecycle?.intake ?? "triage" };
|
||||
} catch {
|
||||
return { hold: "todo", intake: "triage" };
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldHoldActiveFileScopeLease(
|
||||
task: Task,
|
||||
tasks: Task[],
|
||||
@@ -753,7 +786,8 @@ export class Scheduler {
|
||||
*/
|
||||
this.store.on("task:moved", async ({ task, from, to, source }) => {
|
||||
this.lastAutoClaimFingerprint.set(task.id, computeAutoClaimFingerprint(task));
|
||||
if (from === "todo" || to === "todo") {
|
||||
const parked = resolveTaskParkedColumnsSync(this.store, task.id);
|
||||
if (from === parked.hold || to === parked.hold) {
|
||||
this.options.snapshotManager?.invalidate(`task:moved:${from}->${to}`);
|
||||
}
|
||||
// PR Monitoring
|
||||
@@ -793,7 +827,7 @@ export class Scheduler {
|
||||
|
||||
// Mission failure tracking: status/error are cleared during moveTask(in-progress → todo),
|
||||
// so we pair this with failedTaskIds captured from task:updated events.
|
||||
if (task.sliceId && to === "todo" && this.options.onTaskFailed) {
|
||||
if (task.sliceId && to === parked.hold && this.options.onTaskFailed) {
|
||||
if (task.status === "failed" || this.failedTaskIds.has(task.id)) {
|
||||
this.failedTaskIds.delete(task.id);
|
||||
void Promise.resolve(this.options.onTaskFailed(task.id)).catch((err) => {
|
||||
@@ -809,7 +843,7 @@ export class Scheduler {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (!settings.globalPause && !settings.enginePaused) {
|
||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const todoTasks = await this.store.listTasks({ column: parked.hold, slim: true });
|
||||
for (const dependent of todoTasks) {
|
||||
const mentionsCompletedTask = dependent.dependencies.includes(task.id);
|
||||
const currentlyBlockedByCompletedTask = dependent.blockedBy === task.id;
|
||||
@@ -868,7 +902,7 @@ export class Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
if (from === "in-progress" && to === "todo") {
|
||||
if (from === "in-progress" && to === parked.hold) {
|
||||
if (source === "engine") {
|
||||
this.recentEngineTodoRequeues.set(task.id, task.columnMovedAt ?? new Date().toISOString());
|
||||
} else {
|
||||
@@ -891,7 +925,7 @@ export class Scheduler {
|
||||
// Event-driven scheduling: when a task moves to "done" (completion) or "todo" (retry/manual move),
|
||||
// trigger scheduling immediately so waiting tasks can start without waiting
|
||||
// for the next poll interval (up to 15 seconds).
|
||||
if (to === "done" || to === "todo") {
|
||||
if (to === "done" || to === parked.hold) {
|
||||
schedulerLog.log(`Task moved to ${to} — triggering scheduling`);
|
||||
this.schedule();
|
||||
}
|
||||
@@ -943,7 +977,8 @@ export class Scheduler {
|
||||
schedulerLog.warn(`Failed to reset dispatch oscillation state for ${task.id} on unpause: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
if (this.running && (task.column === "todo" || task.column === "triage")) {
|
||||
const unpausedParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
||||
if (this.running && (task.column === unpausedParked.hold || task.column === unpausedParked.intake)) {
|
||||
schedulerLog.log(`Task ${task.id} unpaused — triggering scheduling`);
|
||||
this.schedule();
|
||||
}
|
||||
@@ -969,12 +1004,13 @@ export class Scheduler {
|
||||
this.planningTaskIds.add(task.id);
|
||||
} else if (this.planningTaskIds.has(task.id)) {
|
||||
this.planningTaskIds.delete(task.id);
|
||||
const planningParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
||||
if (
|
||||
this.running
|
||||
&& !task.status
|
||||
&& !task.paused
|
||||
&& !task.userPaused
|
||||
&& (task.column === "todo" || task.column === "triage")
|
||||
&& (task.column === planningParked.hold || task.column === planningParked.intake)
|
||||
) {
|
||||
schedulerLog.log(`Task ${task.id} finished planning — triggering scheduling`);
|
||||
this.schedule();
|
||||
@@ -1019,7 +1055,8 @@ export class Scheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const deletedParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
||||
const todoTasks = await this.store.listTasks({ column: deletedParked.hold, slim: true });
|
||||
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
const dependents = [...todoTasks, ...inProgressTasks];
|
||||
|
||||
@@ -1058,7 +1095,7 @@ export class Scheduler {
|
||||
dependent.id,
|
||||
`Auto-reblocked (FN-5496): unresolved dependency ${nextBlocker} remains after blocker ${task.id} was soft-deleted`,
|
||||
);
|
||||
} else if (dependent.column === "todo") {
|
||||
} else if (dependent.column === deletedParked.hold) {
|
||||
await this.store.updateTask(dependent.id, { blockedBy: null, status: null });
|
||||
await this.store.logEntry(dependent.id, `Auto-unblocked (FN-5496): blocker ${task.id} was soft-deleted`);
|
||||
} else {
|
||||
@@ -1283,11 +1320,30 @@ export class Scheduler {
|
||||
|
||||
for (const agent of linkedAgents) {
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun?.(agent.id);
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-29-17:45 (PR #2518 self-review — CORRECTED):
|
||||
The synthetic column here stands for "this task is parked in the backlog",
|
||||
which is what `evaluateParkedAgentTaskLink` gates on via
|
||||
`isParkedTaskColumn(linkedTask, parkedColumns)`.
|
||||
|
||||
An earlier version of this note claimed the literal made a renamed workflow
|
||||
read as UNPARKED and drop a live agent's link. THAT WAS WRONG, and the
|
||||
mutation test proved it: the literal passed `{column:"todo"}` together with
|
||||
the helper's LEGACY default parked list, so the pair was self-consistent and
|
||||
read as parked either way. This conversion is behaviour-NEUTRAL here.
|
||||
|
||||
What is load-bearing is the pair travelling TOGETHER. The dangerous state is
|
||||
drift — a resolved column checked against the legacy list (or the reverse),
|
||||
which reads as unparked and clears a live agent's link. That invariant, not
|
||||
the conversion, is what the agent-link tests pin.
|
||||
*/
|
||||
const rollbackParked = resolveTaskParkedColumnsSync(this.store, taskId);
|
||||
const proof = evaluateParkedAgentTaskLink({
|
||||
agent,
|
||||
linkedTask: { column: "todo" } as Pick<Task, "column">,
|
||||
linkedTask: { column: rollbackParked.hold } as Pick<Task, "column">,
|
||||
activeRun,
|
||||
hasActiveAgentExecution: this.options.hasActiveAgentExecution,
|
||||
parkedColumns: [rollbackParked.hold, rollbackParked.intake],
|
||||
});
|
||||
if (proof.shouldPreserveParkedLink) {
|
||||
schedulerLog.log(
|
||||
|
||||
Reference in New Issue
Block a user