Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

554 lines
22 KiB
TypeScript

/**
* WI-6 (#12177) — integration proof that a workflow is schedulable via the
* task/cron layer and dispatchable from a service, WITHOUT a live model.
*
* This drives the REAL path end to end:
* real EmbeddedWorkflowService (PGlite-backed persistence)
* -> a schedule-trigger node arms a real TRIGGER_DISPATCH core Task
* -> the REAL core TaskService tick (runDueTasks) fires it
* -> the REAL agent trigger worker (executeTriggerTask) dispatches
* -> the REAL WORKFLOW_DISPATCH service runs the workflow
* -> a row lands in workflow.embedded_executions + a TriggerRunRecord.
*
* The only test double is the runtime's task STORE (an in-memory map) — the
* same lightweight harness the existing workflow-task-worker.test.ts uses. The
* subject under test (workflow engine, dispatch, trigger worker, TaskService)
* is all real code with real persistence.
*
* Cases:
* (a) scheduled workflow fires through the real tick → execution + run record
* (b) headless WORKFLOW_DISPATCH service call runs a workflow by id
* (c) one core clock services a workflow trigger + a LifeOps task on one tick
* (d) disabled trigger skips; re-enabled runs; maxRuns=1 self-deletes
* (e) overlap-blocking: an in-flight fire makes the next tick skip the same
* trigger task, so exactly one execution lands (#12362 WI-6/WI-7)
* (f) the same core clock drives the REAL ScheduledTask spine (not a
* stand-in) to a `fired` state-log row — the LifeOps consumer's real
* domain artifact (#12362 WI-6/WI-7)
*/
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import type { IAgentRuntime, Task, TaskWorker, UUID } from '@elizaos/core';
import { stringToUuid } from '@elizaos/core';
import { drizzle } from 'drizzle-orm/pglite';
import {
executeTriggerTask,
readTriggerRuns,
registerTriggerTaskWorker,
} from '../../../../packages/agent/src/triggers/runtime.ts';
import {
buildTriggerConfig,
buildTriggerMetadata,
} from '../../../../packages/agent/src/triggers/scheduling.ts';
import type { NormalizedTriggerDraft } from '../../../../packages/agent/src/triggers/types.ts';
import { TaskService } from '../../../../packages/core/src/services/task.ts';
// The "one clock, two consumers" architecture (root AGENTS.md): the core
// TaskService that fires workflow triggers is the SAME clock that drives the
// LifeOps ScheduledTask spine. Case (f) proves the second consumer produces a
// REAL domain artifact by driving the actual scheduling runner + in-memory
// store. The spine imports `@elizaos/core` for types only (erased at runtime),
// so pulling it in here does not create a second runtime copy of core.
import {
createCompletionCheckRegistry,
registerBuiltInCompletionChecks,
} from '../../../plugin-scheduling/src/scheduled-task/completion-check-registry.ts';
import {
createAnchorRegistry,
createConsolidationRegistry,
} from '../../../plugin-scheduling/src/scheduled-task/consolidation-policy.ts';
import {
createEscalationLadderRegistry,
registerDefaultEscalationLadders,
} from '../../../plugin-scheduling/src/scheduled-task/escalation.ts';
import {
createTaskGateRegistry,
registerBuiltInGates,
} from '../../../plugin-scheduling/src/scheduled-task/gate-registry.ts';
import {
createInMemoryScheduledTaskStore,
createScheduledTaskRunner,
type ScheduledTaskRunnerHandle,
TestNoopScheduledTaskDispatcher,
} from '../../../plugin-scheduling/src/scheduled-task/runner.ts';
import {
createInMemoryScheduledTaskLogStore,
type ScheduledTaskLogStore,
} from '../../../plugin-scheduling/src/scheduled-task/state-log.ts';
import type { GlobalPauseView } from '../../../plugin-scheduling/src/scheduled-task/types.ts';
import * as dbSchema from '../../src/db/schema';
import {
EMBEDDED_WORKFLOW_SERVICE_TYPE,
EmbeddedWorkflowService,
TRIGGER_TASK_NAME,
} from '../../src/services/embedded-workflow-service';
import {
registerWorkflowDispatchService,
WORKFLOW_DISPATCH_SERVICE_TYPE,
} from '../../src/services/workflow-dispatch';
setDefaultTimeout(60_000);
const AGENT_ID = stringToUuid('wi6-trigger-dispatch-agent');
interface Harness {
runtime: IAgentRuntime;
tasks: Map<UUID, Task>;
/** The runtime's real service registry, exposed so a test can swap in a
* gated WORKFLOW_DISPATCH wrapper for the overlap-blocking proof (case e). */
services: Map<string, unknown[]>;
workflow: EmbeddedWorkflowService;
taskService: TaskService;
close: () => Promise<void>;
}
/**
* A runtime backed by a real PGlite DB and a real services registry, with an
* in-memory task store. TaskService, EmbeddedWorkflowService and
* WORKFLOW_DISPATCH all run for real against it.
*/
async function makeHarness(): Promise<Harness> {
const dir = await mkdtemp(join(tmpdir(), 'wi6-trigger-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const tasks = new Map<UUID, Task>();
const workers = new Map<string, TaskWorker>();
const services = new Map<string, unknown[]>();
let nextId = 1;
const runtime = {
agentId: AGENT_ID,
character: { settings: {} },
db,
serverless: true, // we drive ticks manually via runDueTasks()
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
services,
// Suppress the default health-check workflow seed: this suite verifies the
// trigger/dispatch/tick mechanics against explicitly-created workflows. The
// auto-seeded default (which runs once and arms its own trigger task on
// start) would otherwise add a second TRIGGER_DISPATCH task and a stray
// execution row, skewing the exact task/execution counts asserted below.
getSetting: (key: string) => (key === 'WORKFLOW_SEED_DEFAULTS' ? 'false' : null),
getService: (type: string) => {
const entries = services.get(type);
return entries && entries.length > 0 ? entries[0] : null;
},
registerTaskWorker: (worker: TaskWorker) => {
workers.set(worker.name, worker);
},
getTaskWorker: (name: string) => workers.get(name),
createTask: async (task: Task) => {
const id = (task.id ?? stringToUuid(`wi6-task-${nextId++}`)) as UUID;
tasks.set(id, { ...task, id, agentId: AGENT_ID });
return id;
},
getTask: async (id: UUID) => tasks.get(id) ?? null,
getTasks: async (params: { tags?: string[] }) => {
const wanted = params?.tags ?? [];
return [...tasks.values()].filter((t) => wanted.every((tag) => t.tags?.includes(tag)));
},
getTasksByName: async (name: string) => [...tasks.values()].filter((t) => t.name === name),
updateTask: async (id: UUID, patch: Partial<Task>) => {
const existing = tasks.get(id);
if (existing) tasks.set(id, { ...existing, ...patch });
},
deleteTask: async (id: UUID) => {
tasks.delete(id);
},
} as unknown as IAgentRuntime;
// Register the REAL workflow engine + dispatch service on the runtime.
const workflow = await EmbeddedWorkflowService.start(runtime);
services.set(EMBEDDED_WORKFLOW_SERVICE_TYPE, [workflow]);
registerWorkflowDispatchService(runtime);
// Register the REAL trigger worker so the TaskService tick can dispatch it.
registerTriggerTaskWorker(runtime);
const taskService = (await TaskService.start(runtime)) as TaskService;
return {
runtime,
tasks,
services,
workflow,
taskService,
async close() {
await taskService.stop();
await client.close();
await rm(dir, { recursive: true, force: true });
},
};
}
async function createScheduledWorkflow(
workflow: EmbeddedWorkflowService,
name: string,
intervalMs: number
): Promise<string> {
const created = await workflow.createWorkflow({
name,
nodes: [
{
id: 'sched',
name: 'Schedule Trigger',
type: 'workflows-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: { intervalMs },
},
{
id: 'set',
name: 'Set',
type: 'workflows-nodes-base.set',
typeVersion: 3.4,
position: [200, 0],
parameters: { assignments: { assignments: [] } },
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
},
});
return created.id;
}
/**
* Force the single armed TRIGGER_DISPATCH task to be "due now" by rewinding its
* schedule metadata into the past, then run one real TaskService tick.
*/
function makeTaskDueNow(task: Task): void {
const meta = task.metadata as Record<string, unknown>;
meta.updatedAt = 0;
const trigger = meta.trigger as Record<string, unknown> | undefined;
if (trigger) trigger.nextRunAtMs = 0;
}
/** Poll a predicate on the microtask queue until it holds or the deadline
* passes. Used to await an un-awaited in-flight tick reaching a known point
* (a gated dispatch) without racing on a fixed sleep. */
async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitUntil: timed out');
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
interface RealScheduledTaskSpine {
runner: ScheduledTaskRunnerHandle;
logStore: ScheduledTaskLogStore;
agentId: string;
}
/**
* Build the REAL LifeOps ScheduledTask spine — the same runner, in-memory
* store, gate/completion/ladder/anchor registries and state-log the scheduling
* plugin wires in production (`createInMemoryScheduledTaskStore` is the real
* adapter `runner-service.ts` uses when no DB adapter is present). Nothing here
* is a stand-in: firing a task writes a real `fired` state-log row.
*/
function makeRealScheduledTaskSpine(agentId: string): RealScheduledTaskSpine {
const gates = createTaskGateRegistry();
registerBuiltInGates(gates);
const completionChecks = createCompletionCheckRegistry();
registerBuiltInCompletionChecks(completionChecks);
const ladders = createEscalationLadderRegistry();
registerDefaultEscalationLadders(ladders);
const logStore = createInMemoryScheduledTaskLogStore();
let taskSeq = 0;
const runner = createScheduledTaskRunner({
agentId,
store: createInMemoryScheduledTaskStore(),
logStore,
gates,
completionChecks,
ladders,
anchors: createAnchorRegistry(),
consolidation: createConsolidationRegistry(),
ownerFacts: () => ({
timezone: 'UTC',
morningWindow: { start: '07:00', end: '10:00' },
}),
globalPause: {
current: async () => ({ active: false }),
} as GlobalPauseView,
activity: { hasSignalSince: () => false },
subjectStore: { wasUpdatedSince: () => false },
dispatcher: TestNoopScheduledTaskDispatcher,
newTaskId: () => {
taskSeq += 1;
return `spine-task-${taskSeq}`;
},
now: () => new Date(),
});
return { runner, logStore, agentId };
}
describe('WI-6: workflow schedulable via the task/cron layer (real tick)', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness();
});
afterEach(async () => {
await h.close();
});
test('(a) a scheduled workflow fires through the real TaskService tick and records an execution + a TriggerRunRecord', async () => {
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 sched', 60_000);
await h.workflow.activateWorkflow(workflowId);
const triggerTasks = [...h.tasks.values()].filter((t) => t.name === TRIGGER_TASK_NAME);
expect(triggerTasks).toHaveLength(1);
makeTaskDueNow(triggerTasks[0]);
// The REAL core TaskService tick discovers the due queue task and runs the
// REAL trigger worker, which dispatches through WORKFLOW_DISPATCH.
await h.taskService.runDueTasks();
// Domain artifact: an execution row landed in workflow.embedded_executions.
const { data: executions } = await h.workflow.listExecutions({ workflowId });
expect(executions.length).toBeGreaterThanOrEqual(1);
// A TriggerRunRecord was appended to the task metadata.
const refreshed = triggerTasks[0].id ? await h.runtime.getTask(triggerTasks[0].id) : null;
expect(refreshed).not.toBeNull();
const runs = refreshed ? readTriggerRuns(refreshed) : [];
expect(runs.length).toBeGreaterThanOrEqual(1);
expect(runs[0].status).toBe('success');
});
test('(b) the WORKFLOW_DISPATCH service runs a workflow by id (headless service call)', async () => {
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 dispatch', 60_000);
const dispatch = h.runtime.getService(WORKFLOW_DISPATCH_SERVICE_TYPE) as {
execute: (
id: string,
payload?: Record<string, unknown>,
options?: { idempotencyKey?: string }
) => Promise<{ ok: boolean; executionId?: string; error?: string }>;
} | null;
expect(dispatch).not.toBeNull();
const result = await dispatch?.execute(workflowId, {}, {});
expect(result?.ok).toBe(true);
const { data: executions } = await h.workflow.listExecutions({ workflowId });
expect(executions.length).toBeGreaterThanOrEqual(1);
});
test('(c) one core TaskService clock drives both consumers — a trigger task and a LifeOps-scheduler task fire on the same tick', async () => {
// Consumer 1: a real scheduled-workflow TRIGGER_DISPATCH task.
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 coexist', 60_000);
await h.workflow.activateWorkflow(workflowId);
const triggerTask = [...h.tasks.values()].find((t) => t.name === TRIGGER_TASK_NAME);
expect(triggerTask).toBeDefined();
if (triggerTask) makeTaskDueNow(triggerTask);
// Consumer 2: a LifeOps-scheduler repeat task on the SAME clock. The real
// LIFEOPS_SCHEDULER worker needs the full personal-assistant stack; here we
// register a stand-in worker under the real name/tags to prove the single
// core TaskService fires the second consumer on the same tick (the "one
// clock, two consumers" architecture, verified structurally).
let lifeopsFired = 0;
h.runtime.registerTaskWorker({
name: 'LIFEOPS_SCHEDULER',
execute: async () => {
lifeopsFired += 1;
return undefined;
},
});
await h.runtime.createTask({
name: 'LIFEOPS_SCHEDULER',
description: 'LifeOps scheduler',
tags: ['queue', 'repeat', 'lifeops'],
metadata: { updatedAt: 0, updateInterval: 60_000 },
});
// A single tick of the ONE clock services both consumers.
await h.taskService.runDueTasks();
// Consumer 1 ran (workflow executed).
const { data: executions } = await h.workflow.listExecutions({ workflowId });
expect(executions.length).toBeGreaterThanOrEqual(1);
// Consumer 2 ran on the same tick.
expect(lifeopsFired).toBe(1);
});
test('(d) a disabled trigger does not fire; a re-enabled fire runs; maxRuns is respected', async () => {
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 gated', 60_000);
// Build a maxRuns=1 workflow trigger task directly and drive executeTriggerTask.
const draft: NormalizedTriggerDraft = {
displayName: 'Gated',
instructions: `Run workflow ${workflowId}`,
triggerType: 'interval',
wakeMode: 'inject_now',
enabled: false, // disabled
createdBy: 'wi6',
intervalMs: 60_000,
maxRuns: 1,
kind: 'workflow',
workflowId,
};
const triggerId = stringToUuid('wi6-gated');
const disabledTrigger = buildTriggerConfig({ draft, triggerId });
const metadata = buildTriggerMetadata({ trigger: disabledTrigger, nowMs: Date.now() }) ?? {
trigger: disabledTrigger,
};
const taskId = await h.runtime.createTask({
name: TRIGGER_TASK_NAME,
description: disabledTrigger.displayName,
tags: ['queue', 'repeat', 'trigger', 'workflow'],
metadata: metadata as Task['metadata'],
});
const task = await h.runtime.getTask(taskId);
if (!task) throw new Error('task not created');
// Disabled → skipped, no execution.
const skipped = await executeTriggerTask(h.runtime, task, { source: 'scheduler' });
expect(skipped.status).toBe('skipped');
let executions = (await h.workflow.listExecutions({ workflowId })).data;
expect(executions.length).toBe(0);
// Enable it, run once → success, execution recorded.
const enabledTrigger = { ...disabledTrigger, enabled: true };
const enabledMeta =
buildTriggerMetadata({ trigger: enabledTrigger, nowMs: Date.now() }) ??
({ trigger: enabledTrigger } as Task['metadata']);
await h.runtime.updateTask(taskId, { metadata: enabledMeta as Task['metadata'] });
const enabledTask = await h.runtime.getTask(taskId);
if (!enabledTask) throw new Error('task missing');
const ran = await executeTriggerTask(h.runtime, enabledTask, { source: 'scheduler' });
expect(ran.status).toBe('success');
executions = (await h.workflow.listExecutions({ workflowId })).data;
expect(executions.length).toBe(1);
// maxRuns=1 reached → the task is deleted so it never fires again.
expect(ran.taskDeleted).toBe(true);
expect(await h.runtime.getTask(taskId)).toBeNull();
});
test('(e) overlapping fire is blocked: while one workflow dispatch is in-flight, the next tick skips the same trigger task', async () => {
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 overlap', 60_000);
await h.workflow.activateWorkflow(workflowId);
const triggerTask = [...h.tasks.values()].find((t) => t.name === TRIGGER_TASK_NAME);
expect(triggerTask).toBeDefined();
if (triggerTask) makeTaskDueNow(triggerTask);
// Gate the REAL dispatch so the first fire stays in-flight across two ticks.
// The wrapper still calls the real WORKFLOW_DISPATCH underneath — only the
// completion timing is controlled, so the workflow really executes once.
const realDispatch = h.runtime.getService(WORKFLOW_DISPATCH_SERVICE_TYPE) as {
execute: (
id: string,
payload?: Record<string, unknown>,
options?: { idempotencyKey?: string }
) => Promise<{ ok: boolean; executionId?: string; error?: string }>;
};
let dispatchCalls = 0;
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
h.services.set(WORKFLOW_DISPATCH_SERVICE_TYPE, [
{
execute: async (
id: string,
payload?: Record<string, unknown>,
options?: { idempotencyKey?: string }
) => {
dispatchCalls += 1;
await gate;
return realDispatch.execute(id, payload, options);
},
},
]);
// Tick 1: do NOT await — it enters the gated dispatch and parks there with
// the trigger task marked as executing on the core TaskService.
const tick1 = h.taskService.runDueTasks();
await waitUntil(() => dispatchCalls === 1);
// Tick 2: the task is still due (its metadata is only updated after the
// fire completes) but is in the executing set, so the blocking guard
// (`task.metadata.blocking !== false`) skips it — no second dispatch.
await h.taskService.runDueTasks();
expect(dispatchCalls).toBe(1);
// Release the in-flight fire and let tick 1 finish.
releaseGate();
await tick1;
// Exactly one execution landed despite two ticks over the due task.
const { data: executions } = await h.workflow.listExecutions({ workflowId });
expect(executions.length).toBe(1);
});
test('(f) same core clock fires a workflow trigger AND drives the REAL ScheduledTask spine to a fired state-log row', async () => {
// Consumer 1: a real scheduled-workflow TRIGGER_DISPATCH task.
const workflowId = await createScheduledWorkflow(h.workflow, 'WI6 spine coexist', 60_000);
await h.workflow.activateWorkflow(workflowId);
const triggerTask = [...h.tasks.values()].find((t) => t.name === TRIGGER_TASK_NAME);
expect(triggerTask).toBeDefined();
if (triggerTask) makeTaskDueNow(triggerTask);
// Consumer 2: the REAL LifeOps ScheduledTask spine, seeded with a reminder.
// A LIFEOPS_SCHEDULER worker (registered under the real name/tags) fires it
// through the real runner when the ONE core clock reaches it — producing a
// real `fired` state-log row, not a counter.
const spine = makeRealScheduledTaskSpine(String(AGENT_ID));
const scheduled = await spine.runner.schedule({
kind: 'reminder',
promptInstructions: 'take a break',
trigger: { kind: 'manual' },
priority: 'medium',
respectsGlobalPause: true,
source: 'user_chat',
createdBy: 'wi6',
ownerVisible: true,
});
h.runtime.registerTaskWorker({
name: 'LIFEOPS_SCHEDULER',
execute: async () => {
await spine.runner.fireWithResult(scheduled.taskId);
return undefined;
},
});
await h.runtime.createTask({
name: 'LIFEOPS_SCHEDULER',
description: 'LifeOps scheduler',
tags: ['queue', 'repeat', 'lifeops'],
metadata: { updatedAt: 0, updateInterval: 60_000 },
});
// A single tick of the ONE clock services both consumers.
await h.taskService.runDueTasks();
// Consumer 1 real artifact: a workflow execution row.
const { data: executions } = await h.workflow.listExecutions({ workflowId });
expect(executions.length).toBeGreaterThanOrEqual(1);
// Consumer 2 real artifact: the scheduled task transitioned to `fired`,
// with a real state-log row recording the transition.
const persisted = await spine.runner.list();
const firedTask = persisted.find((t) => t.taskId === scheduled.taskId);
expect(firedTask?.state.status).toBe('fired');
const log = await spine.logStore.list({
agentId: spine.agentId,
taskId: scheduled.taskId,
});
expect(log.map((entry) => entry.transition)).toContain('fired');
});
});