fix(scheduler): resolve task:updated / task:deleted lanes asynchronously (scheduler inert 5 → 0) (#3128)
The last inert guards in `scheduler.ts`. Independent of my other branches. ## Inert-guard ratchet | Scope | Before | After | |---|---:|---:| | `scheduler.ts` | 5 | **0** | | total | 12 | **7** (triage.ts 8 → other worker; executor.ts 4 → #3112) | ## The live bug These read `resolveTaskParkedColumnsSync`, which answers with the **default** workflow in production. On a renamed board the scheduler **never woke** on unpause or planning-finish, and a **deleted blocker never unblocked its dependents** — the card sat behind a task that no longer existed. ## The criterion, restated because I got it wrong before **What blocks a guard is whether its answer is consumed synchronously — not whether the enclosing listener is declared sync.** I assumed the latter earlier in this program and reverted for it. All three fail that test: two only gate `schedule()`, which is itself `async`, fire-and-forget and re-entrance-guarded; the third already sits below an `await getSettings()`. The edge-trigger bookkeeping (`planningTaskIds.delete`) **stays synchronous** on purpose — deferring *that* would let a second update re-enter the branch. ## The union is load-bearing, not defensive Post-U11 the default lineage has no `triage` column, so a **resolved** answer returns `intake: "todo"` where the inert path fell back to `"triage"`. Converting without unioning the legacy ids silently **narrowed** the wake set and stopped waking cards in a legacy-named lane — caught by *"schedules when planning clears in triage"*. **A resolved conversion must be a superset of what it replaces, or it is a behaviour change wearing a vocabulary change's clothes.** That's the reusable lesson here. ## Tests - Drained with the repo's existing **`flushAsyncHandlers`** helper — written for exactly this fire-and-forget shape — rather than loosening any assertion. - **The characterization test flipped, as designed.** `workflow-scheduler-parked-columns-live-e2e.pg.test.ts` asserted *"a dependent in a RENAMED hold column is NEVER unblocked"*, with its author noting: *"expected to flip to null the moment the resolver is fixed — and that flip is the whole point of writing it down."* It flipped. Inverted to a REGRESSION case so the assertion holds the fix rather than the defect; it now matches its own CONTROL arm, which still guards against a vacuous pass. ## Verification - 21 scheduler suites — **361 green**, including the live PostgreSQL e2e - **`pnpm test:gate` green**; eslint and `tsc` clean - Changeset added; `check:changesets` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/scheduler-async-lanes.md
Normal file
7
.changeset/scheduler-async-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Renamed hold and intake lanes now wake the scheduler and unblock dependents correctly.
|
||||||
|
category: fix
|
||||||
|
dev: The `task:updated`/`task:deleted` handlers resolve lanes asynchronously; the wake set unions legacy ids so it stays a superset.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import { Scheduler } from "../scheduler.js";
|
import { Scheduler } from "../scheduler.js";
|
||||||
|
import { flushAsyncHandlers } from "./_flush-async-handlers.js";
|
||||||
|
|
||||||
function createStore() {
|
function createStore() {
|
||||||
const listeners = new Map<string, ((payload: unknown) => void)[]>();
|
const listeners = new Map<string, ((payload: unknown) => void)[]>();
|
||||||
@@ -114,7 +115,7 @@ describe("Scheduler auto-claim snapshot invalidation", () => {
|
|||||||
expect(invalidate).toHaveBeenNthCalledWith(2, "task:updated");
|
expect(invalidate).toHaveBeenNthCalledWith(2, "task:updated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triggers immediate scheduling when a userPaused-only task is unpaused", () => {
|
it("triggers immediate scheduling when a userPaused-only task is unpaused", async () => {
|
||||||
const { store, emit } = createStore();
|
const { store, emit } = createStore();
|
||||||
const scheduler = new Scheduler(store, {});
|
const scheduler = new Scheduler(store, {});
|
||||||
const schedule = vi.spyOn(scheduler, "schedule").mockResolvedValue(undefined);
|
const schedule = vi.spyOn(scheduler, "schedule").mockResolvedValue(undefined);
|
||||||
@@ -123,6 +124,8 @@ describe("Scheduler auto-claim snapshot invalidation", () => {
|
|||||||
emit("task:updated", createTask({ userPaused: true }));
|
emit("task:updated", createTask({ userPaused: true }));
|
||||||
emit("task:updated", createTask({ userPaused: false }));
|
emit("task:updated", createTask({ userPaused: false }));
|
||||||
|
|
||||||
|
await flushAsyncHandlers();
|
||||||
|
|
||||||
expect(schedule).toHaveBeenCalledTimes(1);
|
expect(schedule).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import { Scheduler } from "../scheduler.js";
|
import { Scheduler } from "../scheduler.js";
|
||||||
|
import { flushAsyncHandlers } from "./_flush-async-handlers.js";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:CodingIdeasWorkflow 2026-07-25-13:10:
|
FNXC:CodingIdeasWorkflow 2026-07-25-13:10:
|
||||||
@@ -73,26 +74,29 @@ function createScheduler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Scheduler wakes on the planning -> dispatchable transition", () => {
|
describe("Scheduler wakes on the planning -> dispatchable transition", () => {
|
||||||
it("schedules when planning clears in todo", () => {
|
it("schedules when planning clears in todo", async () => {
|
||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
|
|
||||||
emit("task:updated", createTask({ status: "planning" }));
|
emit("task:updated", createTask({ status: "planning" }));
|
||||||
expect(schedule).not.toHaveBeenCalled(); // still planning — nothing to dispatch yet
|
expect(schedule).not.toHaveBeenCalled(); // still planning — nothing to dispatch yet
|
||||||
|
|
||||||
emit("task:updated", createTask({ status: null }));
|
emit("task:updated", createTask({ status: null }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).toHaveBeenCalledTimes(1);
|
expect(schedule).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("schedules when planning clears in triage", () => {
|
it("schedules when planning clears in triage", async () => {
|
||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
|
|
||||||
emit("task:updated", createTask({ column: "triage", status: "planning" }));
|
emit("task:updated", createTask({ column: "triage", status: "planning" }));
|
||||||
emit("task:updated", createTask({ column: "triage", status: null }));
|
emit("task:updated", createTask({ column: "triage", status: null }));
|
||||||
|
|
||||||
|
await flushAsyncHandlers();
|
||||||
|
|
||||||
expect(schedule).toHaveBeenCalledTimes(1);
|
expect(schedule).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires once per transition, not on every later update", () => {
|
it("fires once per transition, not on every later update", async () => {
|
||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
|
|
||||||
emit("task:updated", createTask({ status: "planning" }));
|
emit("task:updated", createTask({ status: "planning" }));
|
||||||
@@ -100,6 +104,8 @@ describe("Scheduler wakes on the planning -> dispatchable transition", () => {
|
|||||||
emit("task:updated", createTask({ status: null }));
|
emit("task:updated", createTask({ status: null }));
|
||||||
emit("task:updated", createTask({ status: null }));
|
emit("task:updated", createTask({ status: null }));
|
||||||
|
|
||||||
|
await flushAsyncHandlers();
|
||||||
|
|
||||||
expect(schedule).toHaveBeenCalledTimes(1);
|
expect(schedule).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { TaskStore, WorkflowIr } from "@fusion/core";
|
import type { TaskStore, WorkflowIr } from "@fusion/core";
|
||||||
import { Scheduler } from "../scheduler.js";
|
import { Scheduler } from "../scheduler.js";
|
||||||
import { evaluateParkedAgentTaskLink } from "../task-agent-sync.js";
|
import { evaluateParkedAgentTaskLink } from "../task-agent-sync.js";
|
||||||
|
import { flushAsyncHandlers } from "./_flush-async-handlers.js";
|
||||||
|
|
||||||
const WF = "custom:wf";
|
const WF = "custom:wf";
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
|||||||
it("wakes when a card moves INTO the renamed hold column", async () => {
|
it("wakes when a card moves INTO the renamed hold column", async () => {
|
||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
await emit("task:moved", { task: task(), from: "building", to: "drafting", source: "engine" });
|
await emit("task:moved", { task: task(), from: "building", to: "drafting", source: "engine" });
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).toHaveBeenCalled();
|
expect(schedule).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,6 +153,7 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
|||||||
/* The negative half: converting must not turn every move into a wake. */
|
/* The negative half: converting must not turn every move into a wake. */
|
||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
await emit("task:moved", { task: task({ column: "building" }), from: "inbox", to: "building", source: "user" });
|
await emit("task:moved", { task: task({ column: "building" }), from: "inbox", to: "building", source: "user" });
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).not.toHaveBeenCalled();
|
expect(schedule).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -160,6 +163,7 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
|||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
await emit("task:updated", task({ paused: true }));
|
await emit("task:updated", task({ paused: true }));
|
||||||
await emit("task:updated", task({ paused: false }));
|
await emit("task:updated", task({ paused: false }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).toHaveBeenCalled();
|
expect(schedule).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +171,7 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
|||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
await emit("task:updated", task({ column: "inbox", status: "planning" }));
|
await emit("task:updated", task({ column: "inbox", status: "planning" }));
|
||||||
await emit("task:updated", task({ column: "inbox", status: null }));
|
await emit("task:updated", task({ column: "inbox", status: null }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).toHaveBeenCalled();
|
expect(schedule).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +179,7 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
|||||||
const { emit, schedule } = createScheduler();
|
const { emit, schedule } = createScheduler();
|
||||||
await emit("task:updated", task({ column: "building", status: "planning" }));
|
await emit("task:updated", task({ column: "building", status: "planning" }));
|
||||||
await emit("task:updated", task({ column: "building", status: null }));
|
await emit("task:updated", task({ column: "building", status: null }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
expect(schedule).not.toHaveBeenCalled();
|
expect(schedule).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,20 +166,23 @@ pgDescribe("scheduler parked-column resolution against a live store", () => {
|
|||||||
expect(await unblockOutcome(store, DEFAULT_VOCAB, "wf-default-parked")).toBeNull();
|
expect(await unblockOutcome(store, DEFAULT_VOCAB, "wf-default-parked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CHARACTERIZATION — a dependent in a RENAMED hold column is NEVER unblocked", async () => {
|
it("REGRESSION — a dependent in a RENAMED hold column IS unblocked when its blocker is deleted", async () => {
|
||||||
/*
|
/*
|
||||||
The live consequence of the inert sync read. `resolveTaskParkedColumnsSync` answers
|
FNXC:WorkflowResolvedColumns 2026-08-01-02:20 (fleet — the flip this test was written to catch):
|
||||||
`{ hold: "todo" }` for this task even though its workflow's hold column is `backlog`, so the
|
This was a CHARACTERIZATION of the inert sync read: `resolveTaskParkedColumnsSync` answered
|
||||||
reconciliation queries a column this board does not have, finds no dependents, and leaves
|
`{ hold: "todo" }` for a board whose hold column is `backlog`, so the reconciliation queried a
|
||||||
`blockedBy` pointing at a task that no longer exists.
|
column that does not exist, found no dependents, and left `blockedBy` pointing at a deleted task.
|
||||||
|
|
||||||
Asserting the defect, not blessing it. Expected to flip to `null` the moment the resolver is
|
Its author wrote "expected to flip to `null` the moment the resolver is fixed — and that flip is
|
||||||
fixed — and that flip is the whole point of writing it down.
|
the whole point of writing it down." The `task:deleted` handler now resolves asynchronously, so it
|
||||||
|
has flipped, and the assertion is inverted to hold the fix rather than deleted.
|
||||||
|
|
||||||
|
It now asserts the SAME thing as the CONTROL above, which is the point: the renamed board and the
|
||||||
|
default board must behave identically. The control still earns its place — if the settle window
|
||||||
|
were too short, both would return null and this would pass vacuously.
|
||||||
*/
|
*/
|
||||||
const store = h.store();
|
const store = h.store();
|
||||||
const outcome = await unblockOutcome(store, RENAMED_VOCAB, "wf-renamed-parked");
|
|
||||||
|
|
||||||
expect(outcome).not.toBeNull();
|
expect(await unblockOutcome(store, RENAMED_VOCAB, "wf-renamed-parked")).toBeNull();
|
||||||
expect(outcome).toMatch(/^[A-Z]+-\d+$/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -449,6 +449,55 @@ function mergeParkedColumns(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-08-01-01:40 (fleet):
|
||||||
|
The ASYNC twin. The sync one below cannot answer for a custom workflow in production, so any guard that
|
||||||
|
can reach this one must.
|
||||||
|
|
||||||
|
THE CRITERION IS WHETHER THE ANSWER IS CONSUMED SYNCHRONOUSLY, not whether the enclosing listener is
|
||||||
|
declared sync — I got that wrong earlier in this program and it cost a revert. The three call sites
|
||||||
|
converted to this all fail that test: two only feed `schedule()`, which is itself `async`,
|
||||||
|
fire-and-forget and re-entrance-guarded, and the third already sits below an `await getSettings()`.
|
||||||
|
Deferring them by a microtask changes nothing observable.
|
||||||
|
|
||||||
|
Same fail-soft legacy default as its sync twin, so an unresolvable workflow behaves exactly as before.
|
||||||
|
*/
|
||||||
|
async function resolveTaskParkedColumns(store: TaskStore, taskId: string): Promise<{ hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string>; wake: ReadonlySet<string> }> {
|
||||||
|
const legacy = { hold: "todo", intake: "triage", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" };
|
||||||
|
try {
|
||||||
|
const l = resolveLifecycleColumns(await resolveWorkflowIrForTask(store, taskId));
|
||||||
|
const complete = l?.complete ?? legacy.complete;
|
||||||
|
const archived = l?.archived ?? legacy.archived;
|
||||||
|
return {
|
||||||
|
hold: l?.hold ?? legacy.hold,
|
||||||
|
intake: l?.intake ?? legacy.intake,
|
||||||
|
wip: l?.wip ?? legacy.wip,
|
||||||
|
review: l?.review ?? legacy.review,
|
||||||
|
complete,
|
||||||
|
archived,
|
||||||
|
terminal: new Set([complete, archived]),
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-08-01-02:05 (fleet):
|
||||||
|
The wake set UNIONS the legacy ids rather than replacing them, and that is load-bearing rather
|
||||||
|
than defensive. Post-U11 the default lineage has no `triage` column, so a RESOLVED answer returns
|
||||||
|
`intake: "todo"` where the old inert path fell back to `"triage"`. Converting without the union
|
||||||
|
therefore NARROWS the set and stops waking cards that sit in a legacy-named lane — caught by
|
||||||
|
`scheduler-planning-finished-wake.test.ts` -> "schedules when planning clears in triage".
|
||||||
|
|
||||||
|
A resolved conversion must be a superset of what it replaces, or it is a behaviour change wearing
|
||||||
|
a vocabulary change's clothes.
|
||||||
|
*/
|
||||||
|
wake: new Set([l?.hold ?? legacy.hold, l?.intake ?? legacy.intake, legacy.hold, legacy.intake]),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...legacy,
|
||||||
|
terminal: new Set([legacy.complete, legacy.archived]),
|
||||||
|
wake: new Set([legacy.hold, legacy.intake]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveTaskParkedColumnsSync(store: TaskStore, taskId: string): { hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string> } {
|
function resolveTaskParkedColumnsSync(store: TaskStore, taskId: string): { hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string> } {
|
||||||
const legacy = { hold: "todo", intake: "triage", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" };
|
const legacy = { hold: "todo", intake: "triage", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" };
|
||||||
const legacyTerminal: ReadonlySet<string> = new Set([legacy.complete, legacy.archived]);
|
const legacyTerminal: ReadonlySet<string> = new Set([legacy.complete, legacy.archived]);
|
||||||
@@ -1219,11 +1268,15 @@ export class Scheduler {
|
|||||||
schedulerLog.warn(`Failed to reset dispatch oscillation state for ${task.id} on unpause: ${error instanceof Error ? error.message : String(error)}`);
|
schedulerLog.warn(`Failed to reset dispatch oscillation state for ${task.id} on unpause: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const unpausedParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
/* FNXC:WorkflowResolvedColumns 2026-08-01-01:40 (fleet): the answer only gates `schedule()`,
|
||||||
if (this.running && (task.column === unpausedParked.hold || task.column === unpausedParked.intake)) {
|
which is async and fire-and-forget, so resolving it properly costs nothing observable. */
|
||||||
schedulerLog.log(`Task ${task.id} unpaused — triggering scheduling`);
|
void (async () => {
|
||||||
this.schedule();
|
const unpausedParked = await resolveTaskParkedColumns(this.store, task.id);
|
||||||
}
|
if (this.running && unpausedParked.wake.has(task.column)) {
|
||||||
|
schedulerLog.log(`Task ${task.id} unpaused — triggering scheduling`);
|
||||||
|
void this.schedule();
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1246,17 +1299,22 @@ export class Scheduler {
|
|||||||
this.planningTaskIds.add(task.id);
|
this.planningTaskIds.add(task.id);
|
||||||
} else if (this.planningTaskIds.has(task.id)) {
|
} else if (this.planningTaskIds.has(task.id)) {
|
||||||
this.planningTaskIds.delete(task.id);
|
this.planningTaskIds.delete(task.id);
|
||||||
const planningParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
/* FNXC:WorkflowResolvedColumns 2026-08-01-01:40 (fleet): as with the unpause wake above, the
|
||||||
if (
|
answer only gates `schedule()`. The `planningTaskIds.delete` stays SYNCHRONOUS — it is the
|
||||||
this.running
|
edge-trigger bookkeeping, and deferring it would let a second update re-enter this branch. */
|
||||||
&& !task.status
|
void (async () => {
|
||||||
&& !task.paused
|
const planningParked = await resolveTaskParkedColumns(this.store, task.id);
|
||||||
&& !task.userPaused
|
if (
|
||||||
&& (task.column === planningParked.hold || task.column === planningParked.intake)
|
this.running
|
||||||
) {
|
&& !task.status
|
||||||
schedulerLog.log(`Task ${task.id} finished planning — triggering scheduling`);
|
&& !task.paused
|
||||||
this.schedule();
|
&& !task.userPaused
|
||||||
}
|
&& planningParked.wake.has(task.column)
|
||||||
|
) {
|
||||||
|
schedulerLog.log(`Task ${task.id} finished planning — triggering scheduling`);
|
||||||
|
void this.schedule();
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.options.prMonitor) return;
|
if (!this.options.prMonitor) return;
|
||||||
@@ -1299,7 +1357,7 @@ export class Scheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedParked = resolveTaskParkedColumnsSync(this.store, task.id);
|
const deletedParked = await resolveTaskParkedColumns(this.store, task.id);
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowLifecycleColumns 2026-08-01-05:00:
|
FNXC:WorkflowLifecycleColumns 2026-08-01-05:00:
|
||||||
A HALF-CONVERTED PAIR, one line apart. The hold read above already resolved its lane while
|
A HALF-CONVERTED PAIR, one line apart. The hold read above already resolved its lane while
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"total": 29,
|
"total": 11,
|
||||||
"byFile": {
|
"byFile": {
|
||||||
"packages/engine/src/executor.ts": 4,
|
"packages/engine/src/executor.ts": 4,
|
||||||
"packages/engine/src/scheduler.ts": 18,
|
|
||||||
"packages/engine/src/triage.ts": 7
|
"packages/engine/src/triage.ts": 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user