mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat receivers own persistent provider connections and fenced database leases. > - The chat maintenance timer repeatedly queries all eligible receivers, including when none exist. > - Configuration changes and known lease deadlines already identify when discovery is needed. > - This pull request moves receiver discovery to the shared delivery coordinator. > - Empty or settled discovery stops querying, while ownership and recovery remain explicit. ## Linked Issues or Issue Description Refs #17557. This is the next stacked slice after Discord command registration. The base is `codex/sleep-19-discord-command-registration`. **What happened?** The recurring chat maintenance lane scans receiver inventory and reconciles Discord and Photon runtimes even when configuration has not changed. **Expected behavior** Reconstruct receiver work at startup. React to committed configuration changes and local listener retirement. Retry foreign ownership at the saved lease expiry. Stop discovery when there is no remaining obligation. **Steps to reproduce** Start an instance with no chat endpoints. Let startup finish. Observe the recurring receiver inventory query from chat maintenance. With a configured receiver, remove its ownership token while listener shutdown is delayed and signal a configuration refresh: the old implementation can replace the listener before shutdown completes. ## What Changed - Preserve healthy receiver progress after a slow failed batch while retaining per-receiver retry backoff. - Register a receiver discovery worker with scoped refreshes, paged startup inventory, fair batches, and per-receiver or scope failure backoff. - Keep owned receiver identities cached without a discovery deadline, so deletion still stops their listeners. - Use existing transaction signals for configuration, credentials, health, access revocation, company deletion, and provider lifecycle changes. Rename the generic command-only helper to describe its broader configuration contract. - Keep lease-key selection with the receiver owner. Shared scheduling contains no provider protocol. - Retain stopping ownership until listener shutdown and lease release complete. Concurrent acquisition joins the existing stop promise. - Remove receiver discovery from the periodic chat lane. Preserve voice and the other unmigrated duties. - Record producer coverage, failure evidence, limits, and remaining work in `doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md`. ## Verification Published head: `ab39569250f4b699a0ab7a829e66dbe27c15020b`. Discord #17557 has landed at master `5bf0142d45`. A normal merge reconciles its squash ancestry by applying only the recorded 27-file receiver patch from parent `547fdf2139`. Every other path matches that master. Every server and package production/test file is byte-identical to the published `6e88ca0201` source that passed the 131-case run, typecheck and build. Unrelated upstream UI/eval/documentation changes are preserved. This PR targets master. Fresh exact-head reviews and CI are required. - **131 distinct targeted cases pass.** Five complete receiver, coordinator, batching, startup, and GitHub health error-projection suites pass 99 cases. Twelve real Discord automatic-registration/receiver and Slack held-transaction cases pass separately, with 1,581 other chat integration cases deselected. The complete Photon channel suite passes 20 cases. - The standalone receiver and Photon fixtures each reproduced missing-table failures on separate initially empty PostgreSQL 17 databases. Both now apply pending migrations before client use; their complete 20-case files pass on those same databases. The large chat producer suite uses a separate migrated clone. - The old review concerns already have source fixes: discovery stops suppress their own completion wake at `1604a9fcba`; slow failed batches supply `nextRunAtOnError` at `ab56393acc`. Guarded mutations prove the existing regressions: reenabling the stop wake yields two inventory reads instead of one, and removing the error deadline fails the 1,100 ms provider-delay next-wake assertion. Both original source files are restored byte for byte. Normal full worker and real producer suites pass with those fixes. - All 131 distinct targeted cases, production server typecheck and build pass on source `6e88ca0201`. Exact source comparison carries that evidence to this normal merge; the unchanged suites were not rerun just for unrelated upstream changes. Receiver and Photon fixtures self-migrate separate initially empty databases; the large producer fixture uses its own migrated clone. Source is unchanged after these checks. Recorded listener-count warnings and controlled provider failure logs remain; no assertion was weakened. An initial whole-file identity check rejected expected parent queue additions; precise patch comparison confirms those declarations are correctly preserved. - Old server shard 12 failed because GitHub reports that its self-hosted runner lost communication. The grouped-test step is cancelled, and the retained log has no failing assertion or completed summary. The underlying host/resource/network cause is unproven. That interrupted job is not a pass; the updated parent and fixture source require a fresh full CI run. - [Current acceptance and failure record](https://github.com/paperclipai/paperclip/blob/ab39569250f4b699a0ab7a829e66dbe27c15020b/doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md#compatibility-after-discord-squash-merge-october-27). Prior 218-case and full-workspace results describe earlier revisions. No full local repository-suite, multi-writer deployment, Cloud wake, host suspension, or fleet cost claim is made. ## Risks - Process-local transaction hints cover the active database owner and its shared clients. Arbitrary direct SQL or independent configuration writers need explicit wake integration. Startup/resume reconstruction and known foreign-lease takeover remain supported; this does not add general multi-writer discovery. - Active receiver sockets and lease renewals continue. This change does not prove host suspension or extend Cloud alarm coverage. - Configuration signals can arrive during drain, initialization, or shutdown. The worker preserves dirty scopes and the owner joins listener retirement before replacement. - Voice, Teams, milestone projection, publication maintenance, GitHub recovery, and the remaining stack inventory are separate work. - The Discord parent has landed and the receiver child now targets master. Fresh exact-head reviews and all CI must pass before merge. ## Model Used OpenAI GPT-6 via Codex, with repository inspection, code execution, and test tools. The runtime does not expose a more specific deployment identifier or context-window limit. ## 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 linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket identifier - [x] I have run tests locally and they pass - [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 - [ ] All Paperclip CI gates are green - [ ] 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>
246 lines
17 KiB
TypeScript
246 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { applyPendingMigrations, agents, chatEndpoints, chatEndpointLeases, companies, createDb, toolApplications, toolConnections } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { notifyDeliveryWork, DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
import { chatReceiverSchedule } from "../services/chat-channels/runtime/receiver-schedule.js";
|
|
import { registerChatReceiverWork } from "../services/chat-channels/runtime/receiver-work.js";
|
|
import { notifyChatEndpointConfiguration, notifyChatConnectionConfiguration, notifyChatCompanyConfiguration } from "../services/chat-channels/configuration-work-notifications.js";
|
|
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_RECEIVER_TEST_DATABASE_URL");
|
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("chat receiver discovery", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let db: ReturnType<typeof createDb>, companyId: string, agentId: string, applicationId: string;
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
|
let enabled = true, primary = true;
|
|
const errors: unknown[] = [];
|
|
const reconcile = vi.fn(async (_id: string): Promise<"owned" | "foreign"> => "owned"), stop = vi.fn(async (_id: string) => {});
|
|
beforeAll(async () => {
|
|
if (!external) temporary = await startEmbeddedPostgresTestDatabase("chat-receivers-");
|
|
if (external) await applyPendingMigrations(external);
|
|
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
|
}, 60_000);
|
|
beforeEach(async () => {
|
|
companyId = randomUUID(); agentId = randomUUID(); applicationId = randomUUID(); enabled = true; primary = true; errors.length = 0;
|
|
reconcile.mockReset().mockResolvedValue("owned"); stop.mockReset().mockResolvedValue(undefined);
|
|
await db.insert(companies).values({ id: companyId, name: "Receivers", issuePrefix: `R${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Receiver", adapterType: "codex_local" });
|
|
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Receiver", type: "chat" });
|
|
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`)));
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
|
});
|
|
afterEach(async () => {
|
|
await coordinator?.stop(); coordinator = undefined;
|
|
vi.useRealTimers(); vi.restoreAllMocks();
|
|
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
|
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
|
await db.delete(companies).where(eq(companies.id, companyId));
|
|
expect(errors).toEqual([]);
|
|
});
|
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
|
async function endpoint(patch: Partial<typeof chatEndpoints.$inferInsert> = {}, writer = db) {
|
|
const id = patch.id ?? randomUUID(), connectionId = randomUUID();
|
|
return writer.transaction(async tx => {
|
|
await notifyChatEndpointConfiguration(tx, { id, provider: patch.provider ?? "discord" });
|
|
await tx.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: id, uid: id,
|
|
transport: "chat_sdk", connectionPurpose: "channel", enabled: true, status: "active" });
|
|
return (await tx.insert(chatEndpoints).values({ id, companyId, connectionId, assignedAgentId: agentId,
|
|
publicId: id, provider: "discord", status: "active", ...patch }).returning())[0];
|
|
});
|
|
}
|
|
async function start(service = chatReceiverSchedule(db, { reconcile, stop })) {
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => enabled && primary, canReconcile: () => primary, onError: error => errors.push(error) });
|
|
const worker = registerChatReceiverWork(coordinator, service, () => enabled && primary);
|
|
await worker.ready;
|
|
return { worker, service };
|
|
}
|
|
async function settled(expected: number | null = null) {
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
expect(coordinator!.nextWakeAt()).toBe(expected);
|
|
}, { timeout: 5000 });
|
|
}
|
|
async function quiet(ms = 120_000) {
|
|
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction"), execute = vi.spyOn(db, "execute");
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
|
|
} finally { reads.mockRestore(); writes.mockRestore(); execute.mockRestore(); }
|
|
}
|
|
it("does no recurring SQL after empty reconstruction", async () => { await start(); await settled(); await quiet(); });
|
|
it("leaves disabled, archived, and webhook-only endpoints quiet", async () => {
|
|
await endpoint({ status: "paused" }); await endpoint({ status: "archived" }); await endpoint({ provider: "slack" });
|
|
const disabled = await endpoint(); await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, disabled.connectionId));
|
|
const failed = await endpoint({ status: "attention" }); await db.update(toolConnections).set({ healthStatus: "failed" }).where(eq(toolConnections.id, failed.connectionId));
|
|
await start(); await settled(); await quiet(); expect(reconcile).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["discord", "imessage-photon"] as const)("wakes the first %s receiver and retains its identity until removal", async provider => {
|
|
await start(); await settled(); await quiet();
|
|
const row = await endpoint({ provider }); await settled();
|
|
expect(reconcile).toHaveBeenCalledWith(row.id); await quiet();
|
|
await db.transaction(async tx => { await notifyChatConnectionConfiguration(tx, row.connectionId); await tx.delete(chatEndpoints).where(eq(chatEndpoints.id, row.id)); });
|
|
await settled(); expect(stop).toHaveBeenCalledWith(row.id); await quiet();
|
|
});
|
|
it("waits for outer commit and discards rolled-back configuration", async () => {
|
|
await start(); await settled();
|
|
await expect(db.transaction(async tx => { await endpoint({}, tx as unknown as typeof db); await vi.advanceTimersByTimeAsync(60_000); expect(reconcile).not.toHaveBeenCalled(); throw new Error("rollback"); })).rejects.toThrow("rollback");
|
|
await settled(); await quiet();
|
|
let id = "";
|
|
await db.transaction(async tx => { id = (await endpoint({}, tx as unknown as typeof db)).id; await vi.advanceTimersByTimeAsync(60_000); expect(reconcile).not.toHaveBeenCalled(); });
|
|
await settled(); expect(reconcile).toHaveBeenCalledWith(id);
|
|
});
|
|
it("preserves the first receiver written during drain without passive SQL", async () => {
|
|
await start(); await settled(); enabled = false; const row = await endpoint();
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
});
|
|
const drained = idleWorkSnapshot();
|
|
await quiet(60_000); expect(idleWorkSnapshot()).toEqual(drained); expect(reconcile).not.toHaveBeenCalled();
|
|
enabled = true; await settled(); expect(reconcile).toHaveBeenCalledWith(row.id);
|
|
});
|
|
it("reconstructs after standby becomes primary without restarting the worker", async () => {
|
|
await endpoint(); primary = false; await start(); await quiet(60_000);
|
|
primary = true; await settled(); expect(reconcile).toHaveBeenCalledOnce(); await quiet();
|
|
});
|
|
it("waits for a foreign lease, observes its extension, and takes over after release", async () => {
|
|
const row = await endpoint(); let owned = false;
|
|
reconcile.mockImplementation(async () => owned ? "owned" : "foreign");
|
|
const expiry = Date.now() + 60_000;
|
|
await db.insert(chatEndpointLeases).values({ companyId, endpointId: row.id, leaseKey: "discord_gateway_runtime", token: randomUUID(), expiresAt: new Date(expiry) });
|
|
await start(); await settled(expiry); await quiet(10_000); expect(reconcile).toHaveBeenCalledOnce();
|
|
const renewed = expiry + 60_000;
|
|
await db.update(chatEndpointLeases).set({ expiresAt: new Date(renewed) }).where(eq(chatEndpointLeases.endpointId, row.id));
|
|
await vi.advanceTimersByTimeAsync(expiry - Date.now()); await settled(renewed); expect(reconcile).toHaveBeenCalledTimes(2);
|
|
await db.delete(chatEndpointLeases).where(eq(chatEndpointLeases.endpointId, row.id)); owned = true;
|
|
await vi.advanceTimersByTimeAsync(renewed - Date.now()); await settled(); expect(reconcile).toHaveBeenCalledTimes(3); await quiet();
|
|
});
|
|
it("wakes after a local receiver stops and then returns to quiet", async () => {
|
|
const row = await endpoint(); const { worker } = await start(); await settled(); await quiet();
|
|
worker.changed(row.id); await settled(); expect(reconcile).toHaveBeenCalledTimes(2); await quiet();
|
|
});
|
|
it("retains a producer arriving during its scoped refresh", async () => {
|
|
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
|
await start(service); await settled(); let inserted = false;
|
|
service.load = async (...args) => { const rows = await original(...args); if (!inserted) { inserted = true; await endpoint(); } return rows; };
|
|
await endpoint(); await settled(); expect(reconcile).toHaveBeenCalledTimes(2); await quiet();
|
|
});
|
|
it("cancels all cached owned receivers after a company deletion hint", async () => {
|
|
await endpoint(); await endpoint(); await start(); await settled();
|
|
await db.transaction(async tx => { await notifyChatCompanyConfiguration(tx, companyId); await tx.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId)); });
|
|
await settled(); expect(stop).toHaveBeenCalledTimes(2); await quiet();
|
|
});
|
|
it("retains transient load failures and does not repeatedly scan during their backoff", async () => {
|
|
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
|
await start(service); await settled(); let broken = true;
|
|
service.load = async (...args) => { if (broken) throw new Error("load unavailable"); return original(...args); };
|
|
await endpoint(); await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(errors.length).toBeGreaterThan(0); });
|
|
await quiet(10_000); broken = false; await settled(); expect(reconcile).toHaveBeenCalledOnce(); errors.length = 0;
|
|
});
|
|
it("does not schedule blocked takeover attempts before a failed scope refresh can retry", async () => {
|
|
const row = await endpoint();
|
|
reconcile.mockResolvedValue("foreign");
|
|
const expiry = Date.now() + 10_000;
|
|
await db.insert(chatEndpointLeases).values({ companyId, endpointId: row.id, leaseKey: "discord_gateway_runtime", token: randomUUID(), expiresAt: new Date(expiry) });
|
|
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
|
await start(service); await settled(expiry);
|
|
service.load = async () => { throw new Error("scoped inventory unavailable"); };
|
|
await db.transaction(tx => notifyChatConnectionConfiguration(tx, row.connectionId));
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.waitFor(() => expect(errors).toHaveLength(1));
|
|
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now() + 20_000);
|
|
await quiet(10_000);
|
|
service.load = original; reconcile.mockResolvedValue("owned");
|
|
await settled(); errors.length = 0; await quiet();
|
|
});
|
|
it("coalesces a full refresh with its already pending scoped hints", async () => {
|
|
const service = chatReceiverSchedule(db, { reconcile, stop });
|
|
await start(service); await settled();
|
|
const load = vi.spyOn(service, "load");
|
|
await db.transaction(async tx => {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "*");
|
|
await endpoint({}, tx as unknown as typeof db);
|
|
await endpoint({}, tx as unknown as typeof db);
|
|
});
|
|
await settled(); expect(reconcile).toHaveBeenCalledTimes(2);
|
|
expect(load).toHaveBeenCalledOnce(); expect(load.mock.calls[0][0]).toBeUndefined();
|
|
load.mockRestore(); await quiet();
|
|
});
|
|
it("keeps full-refresh backoff when a narrower producer arrives", async () => {
|
|
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
|
await start(service); await settled();
|
|
const load = vi.spyOn(service, "load").mockRejectedValue(new Error("inventory unavailable"));
|
|
await db.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "*"));
|
|
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(errors).toHaveLength(1); });
|
|
const retryAt = coordinator!.nextWakeAt();
|
|
const row = await endpoint();
|
|
await quiet(10_000);
|
|
expect(load).toHaveBeenCalledOnce(); expect(coordinator!.nextWakeAt()).toBe(retryAt);
|
|
load.mockImplementation(original);
|
|
await settled(); expect(reconcile).toHaveBeenCalledWith(row.id);
|
|
expect(load).toHaveBeenCalledTimes(2); expect(load.mock.calls[1][0]).toBeUndefined();
|
|
errors.length = 0; load.mockRestore(); await quiet();
|
|
});
|
|
it("recovers malformed scopes without losing a subsequent valid producer", async () => {
|
|
await start(); await settled();
|
|
await db.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "unknown:old"));
|
|
await endpoint();
|
|
await settled(); expect(reconcile).toHaveBeenCalledOnce(); expect(errors.length).toBeGreaterThan(0); errors.length = 0; await quiet();
|
|
});
|
|
it.each([0, 1100])("drains paged inventory despite a failed receiver after %i ms", async delay => {
|
|
for (let i = 0; i < 105; i++) await endpoint();
|
|
const service = chatReceiverSchedule(db, { reconcile, stop });
|
|
const reads = vi.spyOn(db, "select"); const inventory = await service.load({ kind: "company", id: companyId });
|
|
expect(inventory).toHaveLength(105); expect(reads).toHaveBeenCalledTimes(2); reads.mockRestore();
|
|
const failedId = inventory[0].id;
|
|
reconcile.mockImplementation(async id => {
|
|
if (id === failedId) { vi.setSystemTime(Date.now() + delay); throw new Error("bad credential"); }
|
|
return "owned";
|
|
});
|
|
await start(service);
|
|
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
expect(coordinator!.nextWakeAt()!).toBeLessThanOrEqual(Date.now() + 1000);
|
|
await vi.waitFor(() => expect(new Set(reconcile.mock.calls.map(([id]) => id)).size).toBe(105), { timeout: 5000 });
|
|
expect(reconcile.mock.calls.filter(([id]) => id === failedId)).toHaveLength(1);
|
|
reconcile.mockResolvedValue("owned"); await settled(); errors.length = 0; await quiet();
|
|
});
|
|
it("includes a second company in the first batch despite a larger earlier backlog", async () => {
|
|
for (let i = 1; i <= 30; i++) await endpoint({ id: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}` });
|
|
const firstCompany = companyId, firstAgent = agentId, firstApplication = applicationId;
|
|
const secondCompany = randomUUID(), secondAgent = randomUUID(), secondApplication = randomUUID();
|
|
await db.insert(companies).values({ id: secondCompany, name: "Other receiver company", issuePrefix: `R${secondCompany.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: secondAgent, companyId: secondCompany, name: "Receiver", adapterType: "codex_local" });
|
|
await db.insert(toolApplications).values({ id: secondApplication, companyId: secondCompany, name: "Receiver", type: "chat" });
|
|
try {
|
|
companyId = secondCompany; agentId = secondAgent; applicationId = secondApplication;
|
|
const other = await endpoint({ id: "ffffffff-ffff-4fff-8fff-ffffffffffff" });
|
|
companyId = firstCompany; agentId = firstAgent; applicationId = firstApplication;
|
|
await start();
|
|
expect(reconcile.mock.calls.slice(0, 25).map(([id]) => id)).toContain(other.id);
|
|
await settled(); expect(reconcile).toHaveBeenCalledTimes(31); await quiet();
|
|
} finally {
|
|
companyId = firstCompany; agentId = firstAgent; applicationId = firstApplication;
|
|
await coordinator?.stop(); coordinator = undefined;
|
|
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, secondCompany));
|
|
await db.delete(agents).where(eq(agents.companyId, secondCompany));
|
|
await db.delete(companies).where(eq(companies.id, secondCompany));
|
|
}
|
|
});
|
|
it("joins admitted runtime initialization during shutdown", async () => {
|
|
await start(); await settled(); let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
reconcile.mockImplementation(async () => { await gate; return "owned"; });
|
|
await endpoint(); await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(reconcile).toHaveBeenCalledOnce(); });
|
|
let stopped = false; const stopping = coordinator!.stop().then(() => { stopped = true; });
|
|
await vi.advanceTimersByTimeAsync(1000); expect(stopped).toBe(false);
|
|
release(); await stopping; coordinator = undefined; errors.length = 0;
|
|
await quiet(); expect(reconcile).toHaveBeenCalledOnce();
|
|
});
|
|
});
|