Files
PaperClipAI/server/src/__tests__/chat-native-progress-work.test.ts
T
DottaandPaperclip 1ed0af4186 fix(server): wake native chat progress from committed changes (#17639)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat users receive coarse progress updates while native runs
execute.
> - A recurring chat pass scans saved run events even when no progress
changes.
> - This traffic prevents an idle database from staying idle.
> - Events, lineage changes, and publication state already identify when
projection can change.
> - This PR gives native progress its own commit-driven worker and
schedules only unfinished work.
> - Idle progress stops scanning while restart and recovery preserve
delivery.

## Linked Issues or Issue Description

Refs #17606. This is slice 29 of the recurring-server-work stack, based
on `codex/sleep-28-chat-run-milestones`. It owns native chat progress
only. Related historical optimization: #16845. The parent and this PR
must land in stack order.

**What happened?**

Native chat progress is still called by the one-second chat
reconciliation loop. It repeatedly selects run events with no new work.
Its cursor also truncates PostgreSQL timestamp precision, which can
repeat a rejected full page forever.

**Expected behavior**

Committed events and eligibility changes should wake the projection
owner. Existing rows should reconstruct work after restart. A settled
worker should have no recurring database activity. Full batches, lock
contention, and errors must retain recovery.

**Steps to reproduce**

Start an otherwise idle instance with chat history. Observe the native
progress lane in the recurring chat coordinator. For the cursor defect,
create a full page of suppressed candidates sharing a microsecond
timestamp and one eligible later candidate. The old cursor fails to
advance.

## What Changed

- Extract native progress from lifecycle milestones into its owning
service. Keep the public combined API and budget compatible.
- Register a scoped worker with the existing delivery coordinator and
scheduler. Remove native progress from the recurring chat coordinator.
- Notify before allowlisted event writes in both persistence owners.
Preserve outer commit, rollback, duplicate detection, and the ordinary
non-progress log fast path.
- Extend the existing lineage/authority notification boundary once.
Failed-publication replay and operator retry carry their issue scope.
First working publications signal progress so commit ordering cannot
strand an already-inspected event.
- Limit notification payloads to company/task IDs so wider caller inputs
never serialize database handles or private fields. Load task-binding
fields only for progress event kinds; other native/recovery events
retain their narrow lock query.
- Preserve current safe phrases, cadence, phase deduplication, provider
policy, authorization, and final-output precedence.
- Retry concrete issue/run lock contention. Disarm deleted or terminal
candidates. Preserve scoped dirty work across batches, failures, drain
and shutdown.
- Preserve PostgreSQL timestamp precision in pagination. Add a bounded
regression for the former infinite rejected-page scan.
- Document the producer audit, lifecycle contract, failures, and
remaining limits in
`doc/plans/2026-10-25-native-chat-progress-sleep-audit.md`.

## Verification

Published head: `b7ee820e49fd06c780b36b79d9b1c0883557fb73`, targeting
master after #17606 merged at
`5f0da562dbfdf3229f3cd4bb3dc742ab541986b4`. A normal ancestry merge
preserves the complete 18-path native-progress patch byte-for-byte
relative to its verified parent, with all six unrelated current master
paths intact. No new production change was introduced during final
reconciliation.

- **163 distinct selected cases pass on the verified unchanged patch:**
137 across eight complete suites (native progress, atomic event append,
all five milestone worker suites, and app admission wiring), plus 26
real chat integration cases selected by `native progress`. The other
1,595 chat integration cases were skipped, not passed.
- The full worker suites cover restart, committed events, trace
reprojection, duplicate/no-progress events, first-working publication
ordering, concrete lock contention, issue scope, backlog, drain/resume,
failures and shutdown. Settled history leaves no recurring SQL. The real
chat cases verify operator retry and replay, microsecond pagination,
authority, cadence, phase deduplication, final/question precedence and
redaction.
- The sole earlier parent conflict preserves both this slice's
issue-scoped retry wake and the exact voice conversation scope. The
current parent's milestone producers, onboarding completion guard,
identity query/read-scope coverage, migration 0397 and voice
cleanup/retention repairs remain present.
- Server production typechecking, build and module-boundary checks pass.
The selected server tests live under the excluded `src/__tests__` path
and were executed separately. Each database owner uses a new isolated
app-migrated PostgreSQL fixture.
- [Current-parent acceptance and failure
ledger](https://github.com/paperclipai/paperclip/blob/b7ee820e49fd06c780b36b79d9b1c0883557fb73/doc/plans/2026-10-25-native-chat-progress-sleep-audit.md#current-milestone-parent-acceptance-october-28)
records current counts and scope. Older combined checks and failures
retain their original revision scope and are not added to 163. No new
table, dependency, workflow, migration, or scheduler contract is
introduced.
- **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No
Ponytail review is present, which is an explicitly authorized
nonblocker; no failing verdict is waived. Older head/parent reviews are
not current passes. Actual failing Ponytail findings must be fixed.
Missing/stopped/paused turns without a verdict are authorized
nonblockers; active unfinished reviews retain the original per-head
timer with a 25-minute monitor cutoff respecting the 30-minute maximum.
- No complete local repository matrix, live provider tenant, external
wake, physical host/database suspension or savings claim is made.
Following stack duties remain separate.

## Risks

- Signals are process-local under the existing single active writer
contract. Independent SQL writers or another active process need an
explicit wake integration.
- Allowlisted direct log writes now use a transaction. Native bound runs
register transaction intent; unrelated direct event types retain their
atomic-statement path.
- The shared eligibility boundary wakes both lifecycle and native
projection. It does not decide provider policy or copy event payloads.
- The existing 24-hour selection window, event-time cadence and
authority checks remain. A suppressed event is not retried just because
wall-clock time passes.
- This establishes worker quiet, not whole-instance suspension. Cloud
alarm coverage and conservative sleep gates do not change. Other chat
maintenance remains on its existing cadence.
- This actual-master reconciliation still requires fresh exact-head
review and CI before protected merge. Subsequent slices retain their own
gates.

## Model Used

OpenAI Codex, GPT-6 family, with repository inspection, code execution
and browser-test tools. The exact serving variant and context-window
size 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 `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [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
- [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>
2026-10-28 11:22:29 -05:00

351 lines
21 KiB
TypeScript

import { enqueueChatRunLifecycleMilestones } from "../services/chat-run-publications.js";
import { persistReprojectedWorkspaceDiffs, projectCodexWorkspaceDiffsFromTrace } from "../services/provider-trace-workspace-diff-reprojection.js";
import { randomUUID } from "node:crypto";
import { eq, like, and } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { chatPublications, createDb, heartbeatRunEvents, heartbeatRuns, issues, subscribeDatabaseWork } from "@paperclipai/db";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createChatMilestoneFixtures } from "./helpers/chat-milestone-fixtures.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { nativeChatProgressWork } from "../services/chat-native-progress-work.js";
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
import { NativeRunCoordinatorStore } from "../services/native-runtime/native-run-coordinator-store.js";
import { PaperclipControlPlanePort } from "../services/native-runtime/paperclip-control-plane-port.js";
import type { PrpEvent } from "../vendor/paperclip-runner/index.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_PROGRESS_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("native chat progress work", () => {
let db: ReturnType<typeof createDb>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let fixtures: ReturnType<typeof createChatMilestoneFixtures>;
let f: Awaited<ReturnType<typeof fixtures.create>>;
let runId: string;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let canRun = true;
const errors: unknown[] = [];
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("native-chat-progress-");
db = createDb(external ?? temporary!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
fixtures = createChatMilestoneFixtures(db);
}, 60_000);
beforeEach(async () => {
errors.length = 0; canRun = true; f = await fixtures.create(); runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
nativeIssueId: f.issueId, runtimeMode: "native", status: "running", responsibleUserId: f.userId,
contextSnapshot: { issueId: f.issueId, source: "chat:slack", endpointId: f.endpointId, wakeCommentId: f.commentId },
});
await db.insert(chatPublications).values({ companyId: f.companyId, endpointId: f.endpointId,
conversationId: f.conversationId, issueId: f.issueId, idempotencyKey: `run:${runId}:working:${f.endpointId}`,
payload: { text: "Maya is working…", progressState: "working" }, state: "published",
createdAt: new Date(Date.now() - 60_000),
});
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks(); await fixtures.cleanup();
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
const publications = () => db.select().from(chatPublications).where(and(
eq(chatPublications.issueId, f.issueId), like(chatPublications.idempotencyKey, "%:native:%"),
));
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun, canReconcile: () => true,
onError: error => errors.push(error) });
await nativeChatProgressWork(db).register(coordinator, () => canRun).ready;
}
async function settle() {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator!.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
}
async function quiet() {
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction");
try { await vi.advanceTimersByTimeAsync(120_000); expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled(); }
finally { select.mockRestore(); transaction.mockRestore(); }
}
function event(nativeSource = false, eventType = "item.completed") {
return { companyId: f.companyId, agentId: f.agentId, runId, eventType,
message: "PRIVATE event text", payload: { token: "PRIVATE token" },
...(nativeSource ? { nativeSource: { sourceInstanceId: runId, sourceEventId: "event-1", sourceSeq: 1,
protocolSchemaVersion: 1, canonicalPayload: { eventType } } } : {}),
};
}
it.each([false, true])("wakes from a real committed event after idle (native source: %s)", async nativeSource => {
await start(); await settle(); await quiet(); expect(await publications()).toEqual([]);
const input = event(nativeSource);
const save = (rollback: boolean) => db.transaction(async tx => {
await appendHeartbeatRunEvent(tx as unknown as typeof db, input);
await vi.advanceTimersByTimeAsync(1000);
expect(await publications()).toEqual([]);
if (rollback) throw new Error("outer rollback");
});
await expect(save(true)).rejects.toThrow("outer rollback");
await settle(); expect(await publications()).toEqual([]); await quiet();
await save(false);
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect(await publications()).toEqual([expect.objectContaining({ payload: expect.objectContaining({ text: "Maya is making progress…" }) })]);
}, { timeout: 5000 });
await settle(); await quiet(); expect(errors).toEqual([]);
if (nativeSource) {
const signals: string[] = [];
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.chatNativeProgress, kind => signals.push(kind));
try { expect((await appendHeartbeatRunEvent(db, input)).disposition).toBe("duplicate"); expect(signals).toEqual([]); }
finally { unsubscribe(); }
await quiet();
}
});
it("wakes a saved event when its first working publication commits after event selection", async () => {
await db.delete(chatPublications).where(eq(chatPublications.issueId, f.issueId));
await appendHeartbeatRunEvent(db, event(true));
await start(); await settle(); expect(await publications()).toEqual([]); await quiet();
await db.transaction(async tx => {
expect(await enqueueChatRunLifecycleMilestones(tx as unknown as typeof db, {
scope: { companyId: f.companyId, issueId: f.issueId },
})).toBe(1);
// Model a base transaction that began before the event but committed
// afterwards, without a wall-clock delay in the suite. PostgreSQL's
// default now() is transaction-start time, not commit time.
await tx.update(chatPublications).set({ createdAt: new Date(Date.now() - 300_000) })
.where(eq(chatPublications.idempotencyKey, `run:${runId}:working:${f.endpointId}`));
await vi.advanceTimersByTimeAsync(1000); expect(await publications()).toEqual([]);
});
await settle(); expect(await publications()).toHaveLength(1); await quiet(); expect(errors).toEqual([]);
});
it("reconstructs saved progress after restart and becomes quiet with published history", async () => {
await appendHeartbeatRunEvent(db, event(true));
await start(); await settle(); expect(await publications()).toHaveLength(1); await quiet();
await coordinator!.stop(); await start(); await settle();
expect(await publications()).toHaveLength(1); await quiet(); expect(errors).toEqual([]);
});
it.each(["issue", "run"] as const)("retries a contended %s without another event", async owner => {
await appendHeartbeatRunEvent(db, event(true));
let release!: () => void, entered!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { entered = resolve; });
const lock = db.transaction(async tx => {
if (owner === "issue") await tx.select().from(issues).where(eq(issues.id, f.issueId)).for("update");
else await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update");
entered(); await hold;
});
try {
await ready; await start(); expect(await publications()).toEqual([]);
expect(coordinator!.nextWakeAt()).not.toBeNull();
release(); await lock;
await settle(); expect(await publications()).toHaveLength(1); await quiet(); expect(errors).toEqual([]);
} finally { release(); await lock; }
});
it.each(["port", "coordinator"] as const)("wakes from the real %s event owner after outer commit, with quiet replay", async kind => {
const sessionId = randomUUID(), runnerId = randomUUID();
await db.update(heartbeatRuns).set({ nativeSessionId: sessionId }).where(eq(heartbeatRuns.id, runId));
const binding = { companyId: f.companyId, issueId: f.issueId, runId, agentId: f.agentId,
sessionId, completionContractId: randomUUID(), completionContractSha256: "fixture",
sourceInstanceId: runnerId, controlPlaneSourceInstanceId: "control",
normalizedSessionId: sessionId, runnerSourceInstanceId: runnerId,
completionContractRevision: "1", completionContractCriterionIds: [],
};
const source: PrpEvent = { schema: "paperclip.prp.event.v1", schemaVersion: 1,
sourceEventId: "event-1", sourceSeq: 1, sourceInstanceId: runnerId, sourceKind: "runner",
runId, normalizedSessionId: sessionId, turnId: "turn-1", itemId: "item-1",
eventType: "item.completed", priority: 1, emittedAt: new Date().toISOString(), payload: {},
};
const append = (client: typeof db) => (kind === "port"
? new PaperclipControlPlanePort(client, binding)
: new NativeRunCoordinatorStore(client, binding)).appendEvent(source);
await start(); await settle(); await quiet();
await expect(db.transaction(async tx => {
await append(tx as unknown as typeof db); throw new Error("rollback owner");
})).rejects.toThrow("rollback owner");
await settle(); expect(await publications()).toEqual([]); await quiet();
await db.transaction(async tx => {
await append(tx as unknown as typeof db); await vi.advanceTimersByTimeAsync(1000);
expect(await publications()).toEqual([]);
});
await settle(); expect(await publications()).toHaveLength(1); await quiet();
const signals: string[] = [];
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.chatNativeProgress, kind => signals.push(kind));
try { expect((await append(db)).disposition).toBe("duplicate"); expect(signals).toEqual([]); }
finally { unsubscribe(); }
await quiet(); expect(errors).toEqual([]);
});
it.each(["issue", "run"] as const)("disarms after terminalization wins during a contended %s", async owner => {
await appendHeartbeatRunEvent(db, event(true));
let release!: () => void, entered!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { entered = resolve; });
const lock = db.transaction(async tx => {
if (owner === "issue") await tx.select().from(issues).where(eq(issues.id, f.issueId)).for("update");
else await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update");
entered(); await hold;
await tx.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, runId));
});
try {
await ready; await start(); expect(coordinator!.nextWakeAt()).not.toBeNull();
release(); await lock; await settle(); expect(await publications()).toEqual([]); await quiet();
expect(errors).toEqual([]);
} finally { release(); await lock; }
});
it.each(["first write", "startup"] as const)("retains %s through drain without passive SQL", async kind => {
if (kind === "first write") { await start(); await settle(); }
canRun = false;
await appendHeartbeatRunEvent(db, event(true));
if (kind === "startup") await start();
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
expect(await publications()).toEqual([]); await quiet();
canRun = true; await vi.advanceTimersByTimeAsync(30_000); await settle();
expect(await publications()).toHaveLength(1); await quiet(); expect(errors).toEqual([]);
});
it("retains failed startup selection through drain and retries after resume", async () => {
await appendHeartbeatRunEvent(db, event(true));
const select = vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("temporary read failure"); });
try { await start(); } finally { select.mockRestore(); }
expect(errors.splice(0)).toHaveLength(1); canRun = false;
await vi.advanceTimersByTimeAsync(30_000);
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
await quiet(); expect(await publications()).toEqual([]);
canRun = true; await vi.advanceTimersByTimeAsync(30_000); await settle();
expect(await publications()).toHaveLength(1); await quiet(); expect(errors).toEqual([]);
});
it("leaves unrelated direct log events on their atomic statement path without projection intent", async () => {
await start(); await settle();
const signals: string[] = [];
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.chatNativeProgress, kind => signals.push(kind));
const transaction = vi.spyOn(db, "transaction");
try {
await appendHeartbeatRunEvent(db, event(false, "item.delta"));
expect(transaction).not.toHaveBeenCalled(); expect(signals).toEqual([]);
} finally { transaction.mockRestore(); unsubscribe(); }
await quiet(); expect(await publications()).toEqual([]); expect(errors).toEqual([]);
});
it("wakes saved trace reprojection without live runner ingress and leaves duplicate recovery quiet", async () => {
await start(); await settle(); await quiet();
const projection = projectCodexWorkspaceDiffsFromTrace([{
kind: "frame", direction: "provider_to_client", frameId: 1,
rawBase64: Buffer.from(JSON.stringify({ method: "turn/diff/updated", params: {
turnId: "turn-1", diff: "diff --git a/private.txt b/private.txt\n--- a/private.txt\n+++ b/private.txt\n@@ -1 +1 @@\n-private\n+PRIVATE replacement\n",
} })).toString("base64"),
}]);
expect(projection.turns).toHaveLength(1);
const input = { traceId: randomUUID(), companyId: f.companyId, agentId: f.agentId, runId, projection };
expect(await persistReprojectedWorkspaceDiffs(db, input)).toMatchObject({ created: 1 });
await settle();
expect(await publications()).toEqual([expect.objectContaining({ payload: expect.objectContaining({ text: "Maya is working with files…" }) })]);
await quiet();
const signals: string[] = [];
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.chatNativeProgress, kind => signals.push(kind));
try { expect(await persistReprojectedWorkspaceDiffs(db, input)).toMatchObject({ created: 0 }); expect(signals).toEqual([]); }
finally { unsubscribe(); }
await quiet(); expect(errors).toEqual([]);
});
it("retains a committed event that arrives before the current projection pass returns", async () => {
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const secondRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: secondRunId, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issueId,
runtimeMode: "native", status: "running", responsibleUserId: f.userId, contextSnapshot: source.contextSnapshot,
});
await db.insert(chatPublications).values({
companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId,
issueId: f.issueId, idempotencyKey: `run:${secondRunId}:working:${f.endpointId}`,
payload: { text: "Maya is working…", progressState: "working" }, state: "published",
createdAt: new Date(Date.now() - 60_000),
});
await start(); await settle();
let held = false, release!: () => void;
const pending = new Promise<void>(resolve => { release = resolve; });
const transaction = db.transaction.bind(db);
const barrier = vi.spyOn(db, "transaction").mockImplementation(async (callback, options) => {
const result = await transaction(callback, options);
// Pause after the first projection commits, before its pass can disarm.
// Producer transactions return event records, not an inserted row count.
if (result === 1 && !held) { held = true; await pending; }
return result;
});
try {
await appendHeartbeatRunEvent(db, event(true));
await vi.waitFor(() => expect(held).toBe(true), { timeout: 5000 });
await appendHeartbeatRunEvent(db, { ...event(false), runId: secondRunId });
release(); await settle();
expect(await publications()).toHaveLength(2); barrier.mockRestore();
await quiet(); expect(errors).toEqual([]);
} finally { release(); barrier.mockRestore(); }
});
it("limits an event wake to its issue and reconstructs other saved scopes on restart", async () => {
await start(); await settle();
// Seed rows without signals to expose an accidentally global event wake.
const others = [await fixtures.create(f.companyId), await fixtures.create()];
for (const other of others) {
const id = randomUUID();
await db.insert(heartbeatRuns).values({
id, companyId: other.companyId, agentId: other.agentId, nativeIssueId: other.issueId,
runtimeMode: "native", status: "running", responsibleUserId: other.userId,
contextSnapshot: { issueId: other.issueId, source: "chat:slack", endpointId: other.endpointId, wakeCommentId: other.commentId },
});
await db.insert(chatPublications).values({
companyId: other.companyId, endpointId: other.endpointId, conversationId: other.conversationId,
issueId: other.issueId, idempotencyKey: `run:${id}:working:${other.endpointId}`,
payload: { text: "Maya is working…", progressState: "working" }, state: "published",
createdAt: new Date(Date.now() - 60_000),
});
await db.insert(heartbeatRunEvents).values({ companyId: other.companyId, agentId: other.agentId,
runId: id, seq: 1, eventType: "item.completed" });
}
const saved = (issueId: string) => db.select().from(chatPublications).where(and(
eq(chatPublications.issueId, issueId), like(chatPublications.idempotencyKey, "%:native:%"),
));
await appendHeartbeatRunEvent(db, event(true)); await settle();
expect(await publications()).toHaveLength(1);
for (const other of others) expect(await saved(other.issueId)).toEqual([]);
await quiet(); await coordinator!.stop(); await start(); await settle();
for (const other of others) expect(await saved(other.issueId)).toHaveLength(1);
await quiet(); expect(errors).toEqual([]);
});
it("drains more than one issue batch and becomes quiet with all progress retained", async () => {
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const ids = Array.from({ length: 105 }, () => randomUUID());
await db.insert(heartbeatRuns).values(ids.map(id => ({
id, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issueId,
runtimeMode: "native", status: "running", responsibleUserId: f.userId, contextSnapshot: source.contextSnapshot,
})));
await db.insert(chatPublications).values(ids.map(id => ({
companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId,
issueId: f.issueId, idempotencyKey: `run:${id}:working:${f.endpointId}`,
payload: { text: "Maya is working…", progressState: "working" }, state: "published",
createdAt: new Date(Date.now() - 60_000),
})));
await db.insert(heartbeatRunEvents).values(ids.map(id => ({
companyId: f.companyId, agentId: f.agentId, runId: id, seq: 1, eventType: "item.completed",
})));
await start(); await settle();
expect(await publications()).toHaveLength(ids.length); await quiet(); expect(errors).toEqual([]);
});
it("unsubscribes at shutdown and reconstructs a later event on restart", async () => {
await start(); await settle(); await coordinator!.stop();
await appendHeartbeatRunEvent(db, event(true));
await quiet(); expect(await publications()).toEqual([]);
await start(); await settle(); expect(await publications()).toHaveLength(1); expect(errors).toEqual([]);
});
});