/** * Unit Tests — 批 3 三处修复里**还没有人钉过的那几格**(27-P3C 批 3 对抗复核 F1 / F2 / F3) * * `graph-run-slot-recovery.test.js` 已经钉住了「人点启动 → 退回队列 → 自己被接回来」和 * 「retryNode 干净拒绝」;这个文件补的是同一批修复里**没有测试看着**的那几条路径, * 每一条都对应一种「写不进去但不报错 / 注释承诺了却看不见」的老病: * * F1-C `reviveParentFromChild` 撞坑满 —— 它挂在救援方那条 run 的 `runAuto(...).catch` 下面, * 抛出去只会被吞成一行 console。所以「不静默」必须是**可查的痕迹**:timeline 事件、 * task 行上的原因、以及退得回队列时的自愈。三种结局各钉一条。 * F1-B `completed` + **有 task** 的 run —— 已有用例用的是裸 run(没 task),走的是 * `requeueBlockedRootRun` 第一道 `!run.task_id` 闸。有 task 的那条走的是另一道 * (`run_status !== 'blocked'`),而它才是「对一条跑完的任务点重试」的真实形态。 * F2 `isUntouchedNode` 的五个合取项 —— 已有用例只动了 `attempt`。剩下三个 * (agent_run_id / child_run_id / output_ref)任何一个漏进 DELETE 的 WHERE, * 丢的都是审计轨迹,而且**丢了不报错**。外加白名单对 `completed` 同样成立。 * F3 `sweep()` 的取值域 —— 「一条 queued 都没有、只剩 blocked」的公司是它扩宽取值域的 * 全部理由。旧口径下这家公司在重启后再也不会被 tick,上游修好了下游也永远解不开。 * 强制放行 —— 数据层那道 `DEPENDS_ON_EDITABLE_STATUS` 闸门 + 下一次 tick 真的起跑, * 路由层的端到端用例在另一个文件,这里钉的是它依赖的那两块地基。 * * 隔离手法与 graph-run-slot-recovery.test.js 一致:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 * require(graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,反过来会往仓库 data/ 里写且不报错)。 * agent 一律走注入的假 executor,全程不起 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('批 3 恢复路径的盲区', () => { let wdb; let engine; let sse; let scheduler; let runsDir; const TEST_DB_PATH = '/tmp/test-p3c-recovery-blind-spots.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; const MODULES = [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', '../../services/task-settle', '../../services/task-scheduler', ]; /** 挂起的 executor promise,afterAll 统一放掉,免得留下永不 settle 的 driver */ const pendingResolvers = []; beforeAll(() => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-blind-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); sse = require('../../services/graph-run-sse'); engine = require('../../services/graph-engine'); scheduler = require('../../services/task-scheduler'); }); /** advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些 */ function cleanupLeakedCompanyDirs() { const root = path.resolve(__dirname, '../../../data/companies'); if (!fs.existsSync(root)) return; for (const cid of fs.readdirSync(root)) { if (!cid.startsWith('p3cblind-co-')) continue; const dir = path.join(root, cid); if (fs.existsSync(path.join(dir, 'company.json'))) continue; // 那是真公司 try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } } } afterAll(async () => { for (const r of pendingResolvers) r(); await new Promise((r) => setTimeout(r, 100)); scheduler.stopSweeper(); cleanupLeakedCompanyDirs(); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* 同上 */ } delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; }); beforeEach(() => { const db = wdb.getDb(); db.pragma('foreign_keys = OFF'); db.exec(` DELETE FROM graph_run_loop_records; DELETE FROM graph_run_nodes; DELETE FROM graph_runs; DELETE FROM tasks; DELETE FROM goals; DELETE FROM work_graph_edges; DELETE FROM work_graph_nodes; DELETE FROM work_graphs; DELETE FROM system_agents; `); db.pragma('foreign_keys = ON'); engine.setAgentExecutor(null); engine.setPlanWorkGraph(null); }); let seq = 0; const nextCompany = () => `p3cblind-co-${++seq}`; function ensureAgents(ids) { const now = new Date().toISOString(); const ins = wdb.getDb().prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) `); for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); } function buildGraph(companyId, nodes = ['solo']) { ensureAgents(nodes); const edges = nodes.slice(1).map((to, i) => ({ from: nodes[i], to })); const g = wdb.createWorkGraph({ companyId, nodes, edges }); expect(g.errors || []).toEqual([]); return g.id; } const newGoal = (companyId) => wdb.createGoal({ companyId, title: 'g', description: 'g' }).id; /** * 把公司的那个坑占满。直接写 running 而不是真跑一条 run:这里要的只是「坑上躺着人」, * 而准入守卫数的正是 ACTIVE_ROOT_RUN_STATUS 里的 root run。 */ function occupySlot(companyId) { const run = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['holder']), companyId }); wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(run.id); return run.id; } const nodeByName = (runId, name) => (wdb.getRunState(runId).nodes || []).find((n) => n.name === name); const timeline = (runId) => fs.readFileSync(sse.timelinePath(runId), 'utf8') .split('\n').filter(Boolean).map((l) => JSON.parse(l)); /** * 造出「父 run 一个 subgraph 节点 + 一张会失败的子图」,父图入口节点没有 fail 出边 * (27-P3B 界面走查里走到 blocked 的正是这个形状,见 graph-engine-revive-parent.test.js)。 * @param {object} [opt] * @param {boolean} [opt.withTask] 给父 run 挂一条 task(决定坑满时退不退得回队列) * @param {boolean} [opt.archiveTask] task 建完就归档(退不回队列,但 task 行上仍要留痕) */ function buildParentWithFailingChild({ withTask = false, archiveTask = false } = {}) { const companyId = nextCompany(); ensureAgents(['org-planner', 'wg-only']); const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] }); const parentG = wdb.createWorkGraph({ companyId, nodes: [{ nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }], edges: [], }); expect(childG.errors || []).toEqual([]); expect(parentG.errors || []).toEqual([]); let goalId = null; let taskId = null; if (withTask) { goalId = newGoal(companyId); taskId = wdb.createTask({ goalId, companyId, title: 'parent-task', description: '父 run 对应的任务', executionMode: 'auto', }).id; } const parentRun = wdb.createRun({ graphKind: 'work', graphId: parentG.id, companyId, goalId, taskId, executionMode: 'manual', }); if (taskId) { // current_run_id 必须指着这条 run:task-settle / requeue / 留痕的 WHERE 全靠它认亲 wdb.updateTask(taskId, { status: 'running', currentRunId: parentRun.id, queuedAt: new Date().toISOString() }); if (archiveTask) wdb.updateTask(taskId, { archivedAt: new Date().toISOString() }); } engine.markRunReady(parentRun.id); engine.startRun(parentRun.id, { mode: 'manual' }); engine.setPlanWorkGraph(() => ({ workGraphId: childG.id })); engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点失败' })); return { companyId, parentRunId: parentRun.id, taskId }; } /** 推到「子 run 失败、父节点 blocked、父 run blocked」 */ async function driveToBlocked(parentRunId) { for (let i = 0; i < 12; i += 1) { const r = await engine.advanceTree(parentRunId); if (!r || r.ok === false) break; } const parent = nodeByName(parentRunId, 'org-planner'); expect(parent.status).toBe('blocked'); expect(wdb.getRun(parentRunId).run_status).toBe('blocked'); return parent; } /** 把子 run 修好并跑到 completed —— 这一跳会触发 propagateToParent → reviveParentFromChild */ async function rescueChild(childRunId) { engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' })); const childNode = wdb.getRunState(childRunId).nodes[0]; engine.retryNode(childRunId, childNode.id); for (let i = 0; i < 6; i += 1) { const r = await engine.advanceOne(childRunId); if (!r || r.ok === false) break; } expect(wdb.getRun(childRunId).run_status).toBe('completed'); } // ──────────────────────────────────────────────────────────── // F1-C:reviveParentFromChild 撞坑满 // ──────────────────────────────────────────────────────────── it('F1-C 救援撞坑满且退不回队列(裸 run)→ timeline 上留得下痕迹,父节点一个字都不动', async () => { const { companyId, parentRunId } = buildParentWithFailingChild(); const blockedNode = await driveToBlocked(parentRunId); const holderRunId = occupySlot(companyId); await rescueChild(blockedNode.childRunId); // 父节点/父 run 保持原样:救不回来就别留半截状态(节点 success 而 run 还 blocked 是死局) const after = nodeByName(parentRunId, 'org-planner'); expect(after.status).toBe('blocked'); expect(after.attempt).toBe(blockedNode.attempt); expect(wdb.getRun(parentRunId).run_status).toBe('blocked'); // 痕迹:本函数挂在 runAuto(...).catch 底下,没有事件就等于这件事从没发生过 const trace = timeline(parentRunId) .find((l) => l.kind === 'node.error' && l.payload && l.payload.error_type === 'RunSlotBusyError'); expect(trace, 'reviveParentFromChild 坑满时必须发事件,否则控制台上什么都看不出来').toBeTruthy(); expect(trace.payload.held_by).toEqual([holderRunId]); expect(trace.payload.child_run_id).toBe(blockedNode.childRunId); expect(trace.payload.company_id).toBe(companyId); // ref 必须挂在那个卡住的节点上:控制台的事件流是按 ref 归位的,挂错地方等于发进真空 expect(trace.ref).toBe(blockedNode.id); }); it('F1-C 退不回队列但 task 还在时,原因写到 task 行上,且刻意不带 upstream_failed: 前缀', async () => { // 归档过的 task 退不回队列(requeue 的 WHERE 有 archived_at IS NULL),但留痕照旧要有 const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true, archiveTask: true }); const blockedNode = await driveToBlocked(parentRunId); const holderRunId = occupySlot(companyId); await rescueChild(blockedNode.childRunId); const task = wdb.getTask(taskId); expect(task.status).toBe('blocked'); // 父 run 转 blocked 时由 task-settle 写的 expect(task.blocked_reason).toContain('revive_slot_busy:'); expect(task.blocked_reason).toContain(holderRunId); // 带上 upstream_failed: 前缀就会被调度器当成「推导出来的 blocked」自动推回 queued, // 而这条 run 还等着人处置 —— 那会让任务在 queued/blocked 之间抖动却没人真的在跑 expect(task.blocked_reason.startsWith(wdb.UPSTREAM_BLOCK_PREFIX)).toBe(false); expect(nodeByName(parentRunId, 'org-planner').status).toBe('blocked'); }); it('F1-C 退得回队列时链路自愈:父 run 与 task 一起回 queued,节点照常复活', async () => { const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true }); const blockedNode = await driveToBlocked(parentRunId); expect(wdb.getTask(taskId).status).toBe('blocked'); const holderRunId = occupySlot(companyId); await rescueChild(blockedNode.childRunId); // 两行必须一起动:只动一半的两种死法见 requeueBlockedRootRun 的注释 expect(wdb.getRun(parentRunId).run_status).toBe('queued'); expect(wdb.getTask(taskId).status).toBe('queued'); expect(wdb.getTask(taskId).blocked_reason).toBeNull(); // 节点照常复活 —— 调度器把这条 run 出队时链路接着往下走,救援没白做 expect(nodeByName(parentRunId, 'org-planner').status).toBe('success'); // requeueBlockedRootRun 不走 transitionRun(它要把 task 那行包进同一个事务), // 事件得由 admitOrRequeue 自己补。少了它就是「状态变了却没有事件」,控制台不会重拉快照。 const ev = timeline(parentRunId) .find((l) => l.kind === 'run.status' && l.payload && l.payload.requeued === true); expect(ev, '退回队列必须补一条 run.status 事件').toBeTruthy(); expect(ev.payload.to).toBe('queued'); expect(ev.payload.held_by).toEqual([holderRunId]); // 「调度器把它出队时链路接着往下走」不能只写在注释里:坑一空就得真的收敛掉, // 否则救回来的成果只是换了个姿势卡住(这次卡在 queued 上,界面上还显示「排队中」)。 sse.transitionRun(holderRunId, 'running', 'completed', {}, { summary: '用例直推终态,腾出坑' }); for (let i = 0; i < 40 && wdb.getRun(parentRunId).run_status === 'queued'; i += 1) { await new Promise((r) => setTimeout(r, 10)); } expect(wdb.getRun(parentRunId).run_status).toBe('completed'); expect(wdb.getTask(taskId).status).toBe('completed'); // 已经 success 的那个 subgraph 节点不许被重新点亮:那等于把刚救回来的子 run 再跑一遍 expect(nodeByName(parentRunId, 'org-planner').status).toBe('success'); }); // ──────────────────────────────────────────────────────────── // F1-B:completed + 有 task 的 run 走的是另一道闸 // ──────────────────────────────────────────────────────────── it('F1-B 对一条已跑完的任务点重试撞坑满 → 干净拒绝:节点、attempt、task 全都没动', async () => { const companyId = nextCompany(); const goalId = newGoal(companyId); const holderRunId = occupySlot(companyId); // 一条正常收敛过的任务:run completed、task completed(`current_run_id` 指着它) const task = wdb.createTask({ goalId, companyId, title: 'done', description: '跑完了的任务' }); const run = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['z1']), companyId, goalId, taskId: task.id, }); const db = wdb.getDb(); db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id); wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id }); const node = wdb.getRunState(run.id).nodes[0]; db.prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(node.id); let caught = null; try { engine.retryNode(run.id, node.id); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); expect(caught.heldBy).toEqual([holderRunId]); // completed 的 run 退不回队列(把终态任务塞回队列是批 4 的 rerun 语义,不是这里的事) expect(wdb.getRun(run.id).run_status).toBe('completed'); expect(wdb.getTask(task.id).status).toBe('completed'); const after = wdb.getRunNode(node.id); expect(after.status).toBe('fail'); expect(after.attempt).toBe(0); }); // ──────────────────────────────────────────────────────────── // F2:白名单 + isUntouchedNode 的其余三个合取项 // ──────────────────────────────────────────────────────────── it('F2 白名单不是「非 running 就放行」:completed 的 run 同样一行不删', () => { const companyId = nextCompany(); const graphId = buildGraph(companyId, ['c1', 'c2']); const run = wdb.createRun({ graphKind: 'work', graphId, companyId }); const db = wdb.getDb(); db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id); db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId); db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = 'c2'`).run(graphId); expect(wdb.syncRunNodesToDef(run.id)).toBeNull(); expect(wdb.getRunState(run.id).nodes.length).toBe(2); // 历史 run 的节点行是审计轨迹,不许被改图删掉 }); it('F2 「没跑过」的判据是五个合取项:agent_run_id / child_run_id / output_ref 任一非空都留着', () => { const companyId = nextCompany(); const goalId = newGoal(companyId); const graphId = buildGraph(companyId, ['k0', 'k1', 'k2', 'k3']); const task = wdb.createTask({ goalId, companyId, title: 'q', description: 'q', executionMode: 'auto' }); const run = wdb.createRun({ graphKind: 'work', graphId, companyId, goalId, taskId: task.id, runStatus: 'queued', }); // 三种「跑过但被写回 pending」的痕迹各造一条(回退重置 / reopenChildRun 都会留下这种行) const db = wdb.getDb(); // child_run_id 有外键,得指着一条真存在的子 run(queued 不占坑,不影响别的断言) const childRun = wdb.createRun({ graphKind: 'work', graphId, companyId, runStatus: 'queued', }); const nodes = Object.fromEntries(wdb.getRunState(run.id).nodes.map((n) => [n.name, n])); db.prepare(`UPDATE graph_run_nodes SET status='pending', agent_run_id='ar_x' WHERE id=?`).run(nodes.k1.id); db.prepare(`UPDATE graph_run_nodes SET status='pending', child_run_id=? WHERE id=?`).run(childRun.id, nodes.k2.id); db.prepare(`UPDATE graph_run_nodes SET status='pending', output_ref='out/x.md' WHERE id=?`).run(nodes.k3.id); // 排队期间「重新规划组织」:定义里只剩 k0 db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId); db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name != 'k0'`).run(graphId); const diff = wdb.syncRunNodesToDef(run.id); expect(diff.removed).toEqual([]); // 一条都不许删 expect(diff.kept.sort()).toEqual([nodes.k1, nodes.k2, nodes.k3].map((n) => n.defNodeId).sort()); for (const n of [nodes.k1, nodes.k2, nodes.k3]) expect(wdb.getRunNode(n.id)).toBeTruthy(); }); // ──────────────────────────────────────────────────────────── // F3:sweep 的取值域 // ──────────────────────────────────────────────────────────── it('F3 一条 queued 都没有、只剩 blocked(upstream_failed:*) 的公司,sweep 仍要捡起来', () => { const companyId = nextCompany(); const goalId = newGoal(companyId); // 上游死了(进程重启前 A 就失败了),下游只剩这一条 blocked —— 队里空无一人 const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' }); wdb.updateTask(up.id, { status: 'failed' }); const down = wdb.createTask({ goalId, companyId, title: 'B', description: '下游', dependsOn: [up.id] }); wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` }); // 前提:旧口径(只扫 status='queued')下这家公司根本进不了扫描结果 const queued = wdb.listCompanyTasks(companyId, { status: 'queued' }); expect(queued.length).toBe(0); expect(scheduler.sweep().map((r) => r.companyId)).toContain(companyId); expect(wdb.getTask(down.id).status).toBe('blocked'); // 上游还是死的,这一轮不该动它 // 人把上游 rerun 好了 → 下一次 sweep 自己把下游放回队列(blocked 是推导结果,不落死) wdb.updateTask(up.id, { status: 'completed' }); const results = scheduler.sweep(); expect(results.find((r) => r.companyId === companyId).requeued).toContain(down.id); expect(wdb.getTask(down.id).status).toBe('queued'); expect(wdb.getTask(down.id).blocked_reason).toBeNull(); }); // ──────────────────────────────────────────────────────────── // 强制放行:数据层闸门 + 下一次 tick 真的起跑 // ──────────────────────────────────────────────────────────── it('强制放行:blocked 的 task 能改 depends_on,删掉死上游后下一次 tick 就起跑', async () => { const companyId = nextCompany(); const goalId = newGoal(companyId); // executor 永不 resolve:起跑之后 run 稳稳停在 running,断言不受异步驱动影响 engine.setAgentExecutor(() => new Promise((resolve) => { pendingResolvers.push(() => resolve({ status: 'success' })); })); const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' }); wdb.updateTask(up.id, { status: 'cancelled' }); // 上游被人取消了,永远等不到 completed const down = wdb.createTask({ goalId, companyId, title: 'B', description: '下游', executionMode: 'auto', dependsOn: [up.id], }); const run = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId), companyId, goalId, taskId: down.id, runStatus: 'queued', }); wdb.updateTask(down.id, { status: 'queued', currentRunId: run.id, queuedAt: new Date().toISOString() }); scheduler.tick(companyId); // 依赖推导:queued → blocked expect(wdb.getTask(down.id).status).toBe('blocked'); expect(wdb.getTask(down.id).blocked_reason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}`); // 闸门放行 blocked:挡掉它,界面上那个「强制放行」按钮就必然 4xx,这条任务再无自救出路 expect(() => wdb.updateTask(down.id, { dependsOn: [] })).not.toThrow(); const res = scheduler.tick(companyId); expect(res.requeued).toContain(down.id); expect(res.started.map((s) => s.taskId)).toContain(down.id); expect(wdb.getTask(down.id).status).toBe('running'); expect(wdb.getTask(down.id).blocked_reason).toBeNull(); expect(wdb.getRun(run.id).run_status).toBe('running'); // 收尾:放掉挂起的 executor 并等驱动真的收工。不等的话 beforeEach 会在它跑到一半时清库, // 驱动随后打一行「graph_run_nodes 不存在」的异常 —— 与被测行为无关的噪声,别留给下一个人查。 for (const r of pendingResolvers.splice(0)) r(); await new Promise((r) => setTimeout(r, 60)); }); it('强制放行**不许**顺手清 blocked_reason:清了那条回队列的条件更新就永远命中不了', () => { const companyId = nextCompany(); const goalId = newGoal(companyId); const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' }); wdb.updateTask(up.id, { status: 'failed' }); const down = wdb.createTask({ goalId, companyId, title: 'B', description: '下游', executionMode: 'manual', dependsOn: [up.id], }); wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` }); // 「改依赖 + 顺手把 reason 清成 NULL」——看着更干净,实际是死锁: // requeueDerivedBlockedTask 的 WHERE 里有 blocked_reason LIKE 'upstream_failed:%' wdb.updateTask(down.id, { dependsOn: [], blockedReason: null }); scheduler.tick(companyId); expect(wdb.getTask(down.id).status).toBe('blocked'); // 卡死,且全程不报错 expect(wdb.requeueDerivedBlockedTask(down.id)).toBe(0); // 反过来:reason 留着的那条就能被推回队列 —— 差别只在这一列 wdb.updateTask(down.id, { blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` }); scheduler.tick(companyId); expect(wdb.getTask(down.id).status).toBe('queued'); }); });