mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip manages agents and their runtime configuration. > - Plugins can maintain external agent computers. > - Configuration and file saves currently miss durable plugin delivery. > - A transient activity notification cannot recover after interruption. > - This pull request adds an opt-in agent runtime resource stream. > - The existing dispatcher delivers and acknowledges its committed work. > - Plugins can prepare computers when configuration changes. ## Linked Issues or Issue Description Related: #15631 and #16203 establish agent lifecycle and native durable delivery. **Problem** Plugins cannot reliably react to agent configuration and instruction changes without polling or waiting for execution. **Proposed solution** Add an opt-in `agent_runtime` stream to the existing resource journal. Record changes at the database mutation and managed file audit boundaries. Wake the dispatcher with content-free commit notifications. Provide scoped runtime inputs to the selected lifecycle plugin. **Alternatives considered** Route-only activity events miss service and background writers. Scheduled scans delay changes and repeatedly query unchanged state. **Roadmap alignment** This extends the existing plugin and agent configuration work. ## What Changed - Add durable update/delete events for agent runtime state, including an existing-agent baseline. - Capture configuration changes, instruction edits, deletion, restore, and managed run writeback. - Add commit notifications and a reconnect scan. Register one listener for configured, enabled runtime subscribers. Release it when none remain. - Keep HTTP startup independent of queued plugin callbacks. - Add `ctx.agents.getRuntime`. Require lifecycle capability, invocation company, enabled plugin, and owning lifecycle selection. - Keep legacy agent/project subscriptions isolated. Manual inbox reads retain both legacy streams. - Resolve recovered instruction paths without reading or hashing bundle contents. Use the company reporting graph for runtime health. - Repair missed file events on identical save/deletion retries after a journal failure; preserve content tokens and conditional-write checks. - Correlate file audits with runtime revisions to deduplicate recorded retries without a new table. Bound the lookup with the existing company/timestamp index. - Document the contract, migration, access boundaries, and session-connection requirement. ## Verification - Rebased the full stack onto origin/master after #16507. - Final replayed stack: `pnpm -r typecheck` and `pnpm build` passed. - All 132 affected runtime, skill, connector, publication, instruction, managed-agent, and delivery tests passed after the latest-master rebase. Workspace typecheck and build passed. - Journal failure injection verifies recovery on identical file writes, deletions, and instruction retries from an acknowledged inbox. - Deduplication follow-up: 80 runtime/instruction/delivery tests and server typecheck passed. Repeated retries create no audit/events; old hashes returning after an intervening commit still repair delivery. Built-in/native instruction writer regressions passed all 56 tests. - PR 1 follow-up tests cover valid/cyclic reporting chains, listener registration failure, multiple subscribers, global/company disable, re-enable, unload, and startup with blocked callbacks. - Database coverage includes rollback, journal failure, no-op suppression, file saves/deletion, ordering/replay, owner/company denial, independent-client commits, and real listener loss/reconnect. - SDK/shared suites passed 1,059 tests during implementation. CLI/delivery regressions also passed. - Migration safety, map checks, diff whitespace, and local secrets/PII scan passed. - Hosted CI and security checks are fully green at `b296405e2`. Standard Greptile and Apex both returned 5/5 with no unresolved findings. - The broad local suite encountered an unchanged diagnostic test that rejects the word "private" from an error stack. This worktree lives under /private/tmp. The same unchanged diagnostic suite passed all 108 tests in the original checkout. Hosted full-suite checks provide exact-head verification. ## Risks - Apply migration 0347 before enabling a runtime subscriber. Runtime notifications require session-level PostgreSQL LISTEN. Transaction-mode poolers are unsupported for this opt-in feature. - Database triggers share the mutation transaction. Journal failure prevents configuration commits. Trigger work adds a journal insert to relevant changes. - Filesystem publication and SQL commit are separate boundaries. Direct filesystem edits do not emit hooks. External clients must fence stale revisions and retain conditional-write checks. - The reader returns only that plugin's own instance secrets. Keep them out of UI data, logs, plugin state, and agent files. - No private transport integration or live remote computer qualification is included. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass (focused suites; broad local limitation documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
261 lines
18 KiB
TypeScript
261 lines
18 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createServer, connect, type Socket } from "node:net";
|
|
import { EventEmitter } from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { activityLog, agents, companies, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { pluginAgentRuntimeState } from "../services/plugin-agent-runtime-state.js";
|
|
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
|
|
import { registerPluginLifecycleDelivery } from "../services/plugin-lifecycle-delivery.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
|
import { agentFileStore } from "../services/agent-file-store.js";
|
|
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
|
|
describeDatabase("agent runtime synchronization hooks", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let companyId: string;
|
|
let pluginId: string;
|
|
let agentId: string;
|
|
let home: string;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-agent-runtime-");
|
|
db = createDb(database.connectionString);
|
|
home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-agent-runtime-home-"));
|
|
}, 90_000);
|
|
afterAll(async () => { await database?.cleanup(); await fs.rm(home, { recursive: true, force: true }); });
|
|
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); });
|
|
beforeEach(async () => {
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
companyId = randomUUID(); pluginId = randomUUID(); agentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Runtime fixture", issuePrefix: `R${companyId.slice(0, 6)}` });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "runtime-fixture", version: "1.0.0", status: "ready",
|
|
manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] } as never });
|
|
await db.insert(pluginConfig).values({ pluginId, companyId, configJson: {} });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Runtime agent", lifecycleRequiredPluginIds: [pluginId] });
|
|
});
|
|
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"))).orderBy(resourceLifecycleEvents.id);
|
|
|
|
it("captures all configuration writers while ignoring no-op and heartbeat bookkeeping updates", async () => {
|
|
expect(await events()).toHaveLength(1);
|
|
await db.update(agents).set({ name: "Runtime agent", updatedAt: new Date(), lastHeartbeatAt: new Date(), spendMonthUtc: "2099-01" }).where(eq(agents.id, agentId));
|
|
expect(await events()).toHaveLength(1);
|
|
await db.update(agents).set({ adapterConfig: { model: "fixture" }, permissions: { canCreateAgents: true } }).where(eq(agents.id, agentId));
|
|
await db.update(agents).set({ runtimeConfig: { fixture: true } }).where(eq(agents.id, agentId));
|
|
const rows = await events();
|
|
expect(rows).toHaveLength(3);
|
|
expect(rows.every(row => row.resourceId === agentId)).toBe(true);
|
|
expect(Object.keys(rows[0]).sort()).toEqual(["action", "companyId", "createdAt", "id", "resourceId", "resourceType"]);
|
|
});
|
|
|
|
it("captures instruction saves, deletion and writeback through the shared file store", async () => {
|
|
const actor = { type: "board" as const, userId: "local-board", source: "local_implicit" as const };
|
|
await agentFileStore(db).write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("instructions"), baseHash: null }, actor);
|
|
const saved = await agentFileStore(db).write({ companyId, agentId, path: "notes.txt", bytes: Buffer.from("notes"), baseHash: null }, actor);
|
|
const beforeDelete = (await events()).length;
|
|
await agentFileStore(db).write({ companyId, agentId, path: "notes.txt", bytes: null, baseHash: saved.contentHash }, actor);
|
|
expect(await events()).toHaveLength(beforeDelete + 1);
|
|
expect(await events()).toContainEqual(expect.objectContaining({ resourceId: agentId, action: "update" }));
|
|
});
|
|
|
|
it("records a managed reset even when only its supporting files changed", async () => {
|
|
const before = (await events()).length;
|
|
await db.insert(activityLog).values({ companyId, actorType: "plugin", actorId: pluginId, action: "plugin.managed_agent.reset", entityType: "agent", entityId: agentId });
|
|
expect(await events()).toHaveLength(before + 1);
|
|
});
|
|
|
|
it("rolls configuration and its event back together and rejects a change when journaling fails", async () => {
|
|
const before = await events();
|
|
await expect(db.transaction(async tx => {
|
|
await tx.update(agents).set({ name: "Rolled back" }).where(eq(agents.id, agentId));
|
|
throw new Error("rollback");
|
|
})).rejects.toThrow("rollback");
|
|
expect(await events()).toEqual(before);
|
|
await db.execute((await import("drizzle-orm")).sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT runtime_fixture_failure CHECK (resource_type <> 'agent_runtime') NOT VALID`);
|
|
try { await expect(db.update(agents).set({ name: "Failed" }).where(eq(agents.id, agentId))).rejects.toThrow(); }
|
|
finally { await db.execute((await import("drizzle-orm")).sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT runtime_fixture_failure`); }
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0].name).toBe("Runtime agent");
|
|
expect(await events()).toEqual(before);
|
|
});
|
|
|
|
it.each(["file-write", "file-delete", "instructions"])("repairs runtime delivery when %s is retried after journal failure", async writer => {
|
|
const actor = { type: "board" as const, userId: "local-board", source: "local_implicit" as const };
|
|
const instructions = agentInstructionRevisionService(db);
|
|
const initial = await instructions.commit({ companyId, agentId, entryFile: "AGENTS.md", content: "original", baseRevisionId: null, source: "api" }, actor);
|
|
const store = agentFileStore(db);
|
|
const saved = writer === "file-delete" ? await store.write({ companyId, agentId, path: "notes.txt", bytes: Buffer.from("notes"), baseHash: null }, actor) : null;
|
|
const save = () => writer === "instructions"
|
|
? instructions.commit({ companyId, agentId, entryFile: "AGENTS.md", content: "changed", baseRevisionId: initial.revision.id, source: "api" }, actor)
|
|
: store.write({ companyId, agentId, path: saved ? "notes.txt" : "AGENTS.md", bytes: saved ? null : Buffer.from("changed"), baseHash: saved ? saved.contentHash : initial.revision.contentHash }, actor);
|
|
const inbox = pluginLifecycleInbox(db, pluginId);
|
|
for (const event of await events()) await inbox.acknowledge(companyId, String(event.id));
|
|
const before = await events();
|
|
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT file_runtime_failure CHECK (resource_type <> 'agent_runtime') NOT VALID`);
|
|
try { await expect(save()).rejects.toThrow(); }
|
|
finally { await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT file_runtime_failure`); }
|
|
expect(await events()).toEqual(before);
|
|
expect(await store.read(companyId, agentId, saved ? "notes.txt" : "AGENTS.md", actor)).toEqual(saved ? null : Buffer.from("changed"));
|
|
expect(await save()).toMatchObject({ changed: false });
|
|
expect(await events()).toHaveLength(before.length + 1);
|
|
expect(await inbox.list(companyId, 100, undefined, ["agent_runtime"])).toEqual([expect.objectContaining({ resourceId: agentId, action: "update" })]);
|
|
const repaired = await events();
|
|
const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, agentId)).orderBy(activityLog.id);
|
|
for (let replay = 0; replay < 5; replay++) expect(await save()).toMatchObject({ changed: false });
|
|
expect(await events()).toEqual(repaired);
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, agentId)).orderBy(activityLog.id)).toEqual(audits);
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.revision).toBe(String((await events()).at(-1)!.id));
|
|
});
|
|
|
|
it("repairs a failed save even when that content hash was recorded before an intervening change", async () => {
|
|
const actor = { type: "board" as const, userId: "local-board", source: "local_implicit" as const };
|
|
const store = agentFileStore(db);
|
|
const target = { companyId, agentId, path: "AGENTS.md" };
|
|
const first = await store.write({ ...target, bytes: Buffer.from("first"), baseHash: null }, actor);
|
|
const second = await store.write({ ...target, bytes: Buffer.from("second"), baseHash: first.contentHash }, actor);
|
|
await store.write({ ...target, bytes: Buffer.from("first"), baseHash: second.contentHash }, actor);
|
|
const before = (await events()).length;
|
|
const save = () => store.write({ ...target, bytes: Buffer.from("second"), baseHash: first.contentHash }, actor);
|
|
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT repeated_hash_failure CHECK (resource_type <> 'agent_runtime') NOT VALID`);
|
|
try { await expect(save()).rejects.toThrow(); }
|
|
finally { await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT repeated_hash_failure`); }
|
|
expect(await save()).toMatchObject({ changed: false });
|
|
expect(await events()).toHaveLength(before + 1);
|
|
await save();
|
|
expect(await events()).toHaveLength(before + 1);
|
|
});
|
|
|
|
it("keeps old subscriptions isolated and replays runtime events in order until acknowledged", async () => {
|
|
await db.update(agents).set({ name: "New name" }).where(eq(agents.id, agentId));
|
|
const inbox = pluginLifecycleInbox(db, pluginId);
|
|
expect(await inbox.list(companyId)).toEqual([]);
|
|
const [first] = await inbox.list(companyId, 100, undefined, ["agent_runtime"]);
|
|
expect(await inbox.list(companyId, 100, undefined, ["agent_runtime"])).toEqual([first]);
|
|
const rows = await events();
|
|
await expect(inbox.acknowledge(companyId, String(rows[1].id))).rejects.toMatchObject({ status: 409 });
|
|
await inbox.acknowledge(companyId, first.id);
|
|
expect(await inbox.list(companyId, 100, undefined, ["agent_runtime"])).toEqual([expect.objectContaining({ id: String(rows[1].id) })]);
|
|
});
|
|
|
|
it.each([{}, { instructionsBundleMode: "managed", instructionsRootPath: "/missing/previous-home" }])("recovers effective managed instruction paths from disk for %j", async adapterConfig => {
|
|
const [agent] = await db.update(agents).set({ adapterConfig }).where(eq(agents.id, agentId)).returning();
|
|
const root = resolveManagedInstructionsRoot(agent);
|
|
await fs.mkdir(root, { recursive: true });
|
|
await fs.writeFile(path.join(root, "GUIDE.md"), "restored instructions");
|
|
const state = await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
expect(state).toMatchObject({ instructionsRootPath: root, instructionsEntryFile: "GUIDE.md" });
|
|
expect(await fs.readFile(path.join(state!.instructionsRootPath!, state!.instructionsEntryFile), "utf8")).toBe("restored instructions");
|
|
});
|
|
|
|
it("resolves instructions without reading or hashing bundle contents", async () => {
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
const root = resolveManagedInstructionsRoot(agent);
|
|
await fs.mkdir(root, { recursive: true });
|
|
await fs.writeFile(path.join(root, "AGENTS.md"), "instructions");
|
|
await fs.writeFile(path.join(root, "support.bin"), Buffer.alloc(1024 * 1024));
|
|
const read = vi.spyOn(fs, "readFile");
|
|
expect(await pluginAgentRuntimeState(db, pluginId, companyId, agentId)).toMatchObject({ instructionsRootPath: root, instructionsEntryFile: "AGENTS.md" });
|
|
expect(read.mock.calls.filter(([file]) => String(file).startsWith(`${root}${path.sep}`))).toEqual([]);
|
|
});
|
|
|
|
it("uses the company reporting graph for runtime health data", async () => {
|
|
const [manager] = await db.insert(agents).values({ companyId, name: "Manager" }).returning();
|
|
await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, agentId));
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.agent.orgChainHealth?.status).toBe("healthy");
|
|
await db.update(agents).set({ reportsTo: agentId }).where(eq(agents.id, manager.id));
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.agent.orgChainHealth?.status).toBe("invalid_org_chain");
|
|
});
|
|
|
|
it("scopes runtime inputs to the enabled owning plugin and returns the current revision", async () => {
|
|
const state = await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
expect(state).toMatchObject({ revision: String((await events()).at(-1)!.id), agent: { id: agentId }, instructionsEntryFile: "AGENTS.md", instanceSecrets: {} });
|
|
expect(await pluginAgentRuntimeState(db, pluginId, randomUUID(), agentId)).toBeNull();
|
|
await expect(pluginAgentRuntimeState(db, randomUUID(), companyId, agentId)).rejects.toMatchObject({ status: 403 });
|
|
await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false });
|
|
await expect(pluginAgentRuntimeState(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it("does not broadcast legacy lifecycle events to runtime listeners", async () => {
|
|
const notifications: string[] = [];
|
|
const listener = await db.$client.listen("paperclip_plugin_lifecycle", payload => { notifications.push(payload); });
|
|
try {
|
|
await db.transaction(async tx => {
|
|
await tx.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: randomUUID(), action: "update" });
|
|
await tx.execute(sql`SELECT pg_notify('paperclip_plugin_lifecycle', 'barrier')`);
|
|
});
|
|
await vi.waitFor(() => expect(notifications).toContain("barrier"));
|
|
expect(notifications).toEqual(["barrier"]);
|
|
await db.update(agents).set({ name: "Notify runtime" }).where(eq(agents.id, agentId));
|
|
await vi.waitFor(() => expect(notifications).toEqual(["barrier", ""]));
|
|
} finally { await listener.unlisten(); }
|
|
});
|
|
|
|
it("wakes delivery after a commit on an independent database client, without polling", async () => {
|
|
const other = createDb(database.connectionString);
|
|
const call = vi.fn(async (_plugin, _method, event) => ({ eventId: event.id, status: "acknowledged" }));
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
|
|
const delivery = registerPluginLifecycleDelivery(coordinator, db, { call, isRunning: () => true, onConfigApplied: () => () => {} } as never, new EventEmitter() as never);
|
|
try {
|
|
await delivery.ready;
|
|
await vi.waitFor(() => expect(call).toHaveBeenCalled(), { timeout: 5000 });
|
|
const delivered = () => call.mock.calls.filter(call => call[2].companyId === companyId);
|
|
const initialCount = (await events()).length;
|
|
await vi.waitFor(() => expect(delivered()).toHaveLength(initialCount), { timeout: 5000 });
|
|
const before = delivered().length;
|
|
await other.transaction(async tx => {
|
|
await tx.update(agents).set({ name: "Independent writer" }).where(eq(agents.id, agentId));
|
|
expect(delivered()).toHaveLength(before);
|
|
});
|
|
await vi.waitFor(() => expect(delivered().length).toBeGreaterThan(before), { timeout: 5000 });
|
|
expect(delivered().at(-1)![2]).toMatchObject({ companyId, resourceId: agentId, resourceType: "agent_runtime" });
|
|
} finally { delivery.dispose(); await coordinator.stop(); await other.$client.end(); }
|
|
});
|
|
it("recovers work committed during real listener connection loss without another write", async () => {
|
|
const target = new URL(database.connectionString);
|
|
const upstreamHost = target.hostname; const upstreamPort = Number(target.port);
|
|
let blocked = false;
|
|
const sockets = new Set<Socket>();
|
|
const proxy = createServer(client => {
|
|
if (blocked) { client.destroy(); return; }
|
|
const upstream = connect(upstreamPort, upstreamHost);
|
|
for (const socket of [client, upstream]) {
|
|
sockets.add(socket);
|
|
socket.on("error", () => { client.destroy(); upstream.destroy(); });
|
|
socket.on("close", () => { sockets.delete(socket); client.destroy(); upstream.destroy(); });
|
|
}
|
|
client.pipe(upstream); upstream.pipe(client);
|
|
});
|
|
await new Promise<void>(resolve => proxy.listen(0, "127.0.0.1", resolve));
|
|
target.hostname = "127.0.0.1"; target.port = String((proxy.address() as { port: number }).port);
|
|
const listenerDb = createDb(target.toString(), { connectTimeoutSeconds: 1 });
|
|
const call = vi.fn(async (_plugin, _method, event) => ({ eventId: event.id, status: "acknowledged" }));
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: listenerDb, canRun: () => true, onError: () => {} });
|
|
const delivery = registerPluginLifecycleDelivery(coordinator, listenerDb, { call, isRunning: () => true, onConfigApplied: () => () => {} } as never, new EventEmitter() as never);
|
|
try {
|
|
await delivery.ready;
|
|
const inbox = pluginLifecycleInbox(db, pluginId);
|
|
await vi.waitFor(async () => expect(await inbox.list(companyId, 100, undefined, ["agent_runtime"])).toEqual([]), { timeout: 5000 });
|
|
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 5000 });
|
|
blocked = true;
|
|
for (const socket of sockets) socket.destroy();
|
|
await db.update(agents).set({ name: "Changed while disconnected" }).where(eq(agents.id, agentId));
|
|
const queued = (await events()).at(-1)!;
|
|
expect(call.mock.calls.some(call => call[2].id === String(queued.id))).toBe(false);
|
|
blocked = false;
|
|
await vi.waitFor(() => expect(call.mock.calls.some(call => call[2].id === String(queued.id))).toBe(true), { timeout: 10000 });
|
|
} finally {
|
|
blocked = false; delivery.dispose(); await coordinator.stop(); await listenerDb.$client.end({ timeout: 1 });
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>(resolve => proxy.close(() => resolve()));
|
|
}
|
|
}, 20000);
|
|
|
|
});
|