Files
PaperClipAI/server/src/__tests__/durable-chat-wakeup.test.ts
T
DottaandPaperclip 3fc64c5156 feat(slack): add Require at-mention setting (#15612)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Slack messages can create tasks and continue existing tasks.
> - A reply in a subscribed thread can start agent work without a new
mention.
> - Some users want to control each reply with an explicit bot mention.
> - This pull request adds a Require at-mention setting to Slack app
settings.
> - The setting checks new messages and queued messages before they
start work.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Slack app settings and the rules that admit inbound messages.

**Subsystem affected**

Server, UI, database, and shared API contracts.

**Current behavior**

After the bot joins a thread, an ordinary reply can wake the agent.
Direct messages do not need a bot mention.

**Proposed behavior**

Add Require at-mention below Allow direct messages. When enabled, each
channel message, thread reply, and direct message must mention the bot.
Direct messages must also be allowed.

**Reason and benefit**

Users can prevent ordinary conversation from starting agent work.

**Breaking changes**

New Slack connections require a bot mention by default. Existing
connections keep their saved setting. Work that has already started is
not cancelled.

**Additional context**

A search for existing at-mention PR titles found no duplicate.

## What Changed

- Add the endpoint field, schema validation, API response, and UI
toggle. New Slack connections enable the setting.
- Add an idempotent database migration and generated metadata. Renumber
it to 0321 after current master.
- Check the current setting at inbound admission, task mutation, and the
first heartbeat queue claim.
- Keep Slack tool access and recovery for already-running work when the
setting changes.
- Cancel queued batches that contain an unmentioned input, including
coalesced messages.
- Dispatch promoted follow-ups after the scheduler releases its agent
start lock. Preserve these checks in the new queue and run-control
modules.
- Filter unmentioned messages without retaining their content.
- Keep provider-message deduplication and existing access checks.
- Add thread, DM, duplicate-delivery, queued-message, and new-connection
default regression tests.
- Document the setting and its scope.

## Verification

- Previous head `16791bb7`: typecheck, build, all server shards, Runner
checks, and E2E passed. Two chat shards failed in three fixtures that
used unmentioned Slack messages.
- Update setup follow-ups to mention the Slack bot. Disable the mention
rule explicitly in the callback ordering test, which tests ordinary
subscribed replies.
- Previous local checks passed: six Slack integration cases, all 46
durable chat scheduler tests, all 24 queue/run-control tests, migration
safety, and UI token gates.
- Greptile reviewed `16791bb7` with 5/5. All review threads are
resolved.
- Head `53163c20f`: all five focused integration cases passed. These
cover all three prior CI failures and both default-on channel/DM cases.
Fresh CI and Greptile are running.
- Full local recursive typecheck, build, and repository tests were not
repeated for this fixture-only change. CI supplies these checks.
- Manual check: create a Slack connection. Require at-mention must be
on. An ordinary thread reply or DM must not wake the agent. A bot
mention must wake it once. Disable the setting and confirm ordinary
replies work again.

## Risks

- The migration adds a non-null boolean with a false default. Apply it
before the new server runs.
- The setting also applies to DMs. The UI help text states this.
- Enabling it blocks queued unmentioned work but does not cancel a run
that has already started.
- Full local verification is limited by the environment constraints
above; latest-head CI must pass before merge.

## Model Used

OpenAI GPT-6 (Codex agent), with reasoning, tool use, and code
execution. The runtime does not expose a more specific model version or
context-window size.

## 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
- [ ] 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-09 14:03:02 -05:00

1427 lines
50 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
agentWakeupRequests,
chatConversations,
chatActions,
chatDeliveries,
chatEndpoints,
chatMessageLinks,
chatPublications,
issueComments,
issueRecoveryActions,
issues,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.js";
import {
authorizeFailedChatRunRetryWake,
authorizeCommittedChatResponse,
CommittedChatResponseAuthorizationError,
registerCommittedChatResponseAuthority,
createDurableChatWakeupRequest,
FailedChatRunRetryAuthorizationError,
registerFailedChatRunRetryAuthority,
} from "../services/durable-chat-wakeup.js";
import { conflict } from "../errors.js";
import type { ServerAdapterModule } from "../adapters/index.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import {
registerServerAdapter,
runningProcesses,
unregisterServerAdapter,
} from "../adapters/index.js";
describe("durable inbound chat scheduler receipts", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const liveRunIds = new Set<string>();
const unregisterAuthorities: Array<() => void> = [];
const execute = vi.fn<ServerAdapterModule["execute"]>(async (input) => {
const issueId = String(input.context.issueId);
await db.insert(issueComments).values({
companyId: input.agent.companyId,
issueId,
authorAgentId: input.agent.id,
createdByRunId: input.runId,
body: "Exact retry dispatched",
});
await db
.update(issues)
.set({ status: "done" })
.where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
summary: "Exact retry dispatched",
};
});
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase(
"chat-wakeup-receipts-",
);
db = createDb(temporary.connectionString);
registerServerAdapter({
type: "durable_chat_retry_test",
execute,
testEnvironment: async () => ({
adapterType: "durable_chat_retry_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
});
}, 30_000);
afterEach(() => {
execute.mockClear();
for (const unregister of unregisterAuthorities.splice(0)) unregister();
for (const runId of liveRunIds) runningProcesses.delete(runId);
liveRunIds.clear();
});
afterAll(async () => {
unregisterServerAdapter("durable_chat_retry_test");
await temporary.cleanup();
});
async function fixture(deferred = false) {
const companyId = randomUUID(),
agentId = randomUUID(),
issueId = randomUUID(),
activeRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Durable wake",
issuePrefix: `D${companyId.slice(0, 7)}`,
defaultResponsibleUserId: "board-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Maya",
role: "ceo",
status: "running",
adapterType: "process",
adapterConfig: {},
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } },
});
await db.insert(heartbeatRuns).values({
id: activeRunId,
companyId,
agentId,
status: "running",
// This fixture-owned slot has no provider process/work. Its retirement
// must not impersonate an ambiguous legacy provider failure.
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
contextSnapshot: { issueId: deferred ? issueId : randomUUID() },
});
runningProcesses.set(activeRunId, {
child: {} as never,
graceSec: 0,
processGroupId: null,
});
liveRunIds.add(activeRunId);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Bound chat task",
status: "in_progress",
assigneeAgentId: agentId,
responsibleUserId: "board-user",
...(deferred
? {
executionRunId: activeRunId,
executionAgentNameKey: "maya",
executionLockedAt: new Date(),
}
: {}),
});
const heartbeat = heartbeatService(db);
const authorize = vi.fn(async () => {});
const request = (commentId = randomUUID(), actor = "board-user") =>
createDurableChatWakeupRequest({
id: randomUUID(),
companyId,
agentId,
issueId,
commentId,
requestedByActorType: "user",
requestedByActorId: actor,
requestedAt: new Date(),
authorize,
});
const wake = (durableChatRequest: ReturnType<typeof request>) =>
queueIssueAssignmentWakeup({
heartbeat,
issue: { id: issueId, assigneeAgentId: agentId, status: "in_progress" },
reason: "External chat message received",
mutation: "chat_message_received",
contextSource: "chat:slack",
requestedByActorType: "user",
requestedByActorId: durableChatRequest.requestedByActorId,
wakeCommentId: durableChatRequest.commentId,
durableChatRequest,
rethrowOnError: true,
});
return {
companyId,
agentId,
issueId,
activeRunId,
heartbeat,
authorize,
request,
wake,
};
}
it.each(["unmentioned", "mentioned", "coalesced", "recovery", "deferred"])("checks Slack mentions after a real heartbeat queue wait (%s)", async (scenario) => {
const mentioned = scenario === "mentioned" || scenario === "coalesced";
const allowed = scenario === "mentioned" || scenario === "recovery";
const f = await fixture();
await db.update(agents).set({ adapterType: "durable_chat_retry_test" }).where(eq(agents.id, f.agentId));
const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID();
await db.insert(toolApplications).values({ id: applicationId, companyId: f.companyId, name: "Mention gate", type: "chat" });
await db.insert(toolConnections).values({ id: connectionId, companyId: f.companyId, applicationId,
name: "Mention gate", uid: randomUUID(), connectionPurpose: "channel", transport: "chat_sdk", status: "active" });
await db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId,
provider: "slack", publicId: randomUUID(), assignedAgentId: f.agentId, status: "active" });
const request = f.request();
await db.insert(issueComments).values({ id: request.commentId, companyId: f.companyId, issueId: f.issueId,
authorUserId: "board-user", body: mentioned ? "@bot work" : "Work" });
const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId,
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed",
normalizedEvent: { trigger: "direct_message", message: { mentionedBot: mentioned } } }).returning();
await db.insert(chatActions).values({ id: request.id, companyId: f.companyId, endpointId,
deliveryId: delivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed",
payload: { issueId: f.issueId, commentId: request.commentId } });
await f.wake(request);
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, request.id));
expect(receipt.status).toBe("queued");
const [queuedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!));
expect(queuedRun.startedAt).toBeNull();
const originalStartedAt = new Date(Date.now() - 60_000);
if (scenario === "recovery") {
// Same-run retries retain their first claim time, even when queued again.
await db.update(heartbeatRuns).set({ startedAt: originalStartedAt })
.where(eq(heartbeatRuns.id, receipt.runId!));
}
let deferredRequestId: string | undefined;
if (scenario === "coalesced" || scenario === "deferred") {
const second = f.request();
await db.insert(issueComments).values({ id: second.commentId, companyId: f.companyId, issueId: f.issueId,
authorUserId: "board-user", body: scenario === "deferred" ? "@bot follow-up" : "Unmentioned follow-up" });
const [secondDelivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId,
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed",
normalizedEvent: { trigger: "direct_message", message: { mentionedBot: scenario === "deferred" } } }).returning();
await db.insert(chatActions).values({ id: second.id, companyId: f.companyId, endpointId,
deliveryId: secondDelivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed",
payload: { issueId: f.issueId, commentId: second.commentId } });
await db.update(issues).set({ executionRunId: receipt.runId, executionAgentNameKey: "maya", executionLockedAt: new Date() })
.where(eq(issues.id, f.issueId));
if (scenario === "deferred") {
// Seed the persisted queue state left by a deferred follow-up before
// its predecessor is returned to the unstarted queue.
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, receipt.runId!));
runningProcesses.set(receipt.runId!, { child: {} as never, graceSec: 0, processGroupId: null });
liveRunIds.add(receipt.runId!);
}
await f.wake(second);
const [coalesced] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, second.id));
if (scenario === "deferred") {
expect(coalesced).toMatchObject({ status: "deferred_issue_execution", runId: null });
deferredRequestId = second.id;
runningProcesses.delete(receipt.runId!);
liveRunIds.delete(receipt.runId!);
await db.update(heartbeatRuns).set({ status: "queued" }).where(eq(heartbeatRuns.id, receipt.runId!));
} else {
expect(coalesced).toMatchObject({ status: "coalesced", runId: receipt.runId });
}
}
await db.update(chatEndpoints).set({ requireAtMention: true }).where(eq(chatEndpoints.id, endpointId));
await f.heartbeat.cancelRun(f.activeRunId, "Fixture slot released", { suppressImmediateRecovery: true });
await f.heartbeat.drainActiveRunExecutions();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!));
expect(run.status).toBe(allowed ? "succeeded" : "cancelled");
expect(execute).toHaveBeenCalledTimes(allowed || scenario === "deferred" ? 1 : 0);
if (deferredRequestId) {
const [promoted] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, deferredRequestId));
const [followup] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, promoted.runId!));
expect(followup.status).toBe("succeeded");
}
if (!allowed) expect(run.errorCode).toBe("chat_mention_required");
if (scenario === "recovery") expect(run.startedAt).toEqual(originalStartedAt);
});
async function executionHold(f: { companyId: string; issueId: string; agentId: string }) {
const [action] = await db.insert(issueRecoveryActions).values({
companyId: f.companyId, sourceIssueId: f.issueId, kind: "active_run_watchdog",
ownerType: "board", returnOwnerAgentId: f.agentId,
cause: "legacy_execution_requires_reconciliation", status: "resolved",
fingerprint: randomUUID(), evidence: { automaticRecovery: { replay: "blocked" } },
nextAction: "Check the stopped execution.",
}).returning();
return () => db.update(issueRecoveryActions).set({ evidence: {} }).where(eq(issueRecoveryActions.id, action!.id));
}
it("defers held inbound chat exactly once and keeps its authority separate from a generic wake", async () => {
const f = await fixture();
const clearHold = await executionHold(f);
const request = f.request();
const wake = () => f.heartbeat.wakeup(f.agentId, {
source: "on_demand", triggerDetail: "manual", reason: "issue_commented",
payload: { issueId: f.issueId, commentId: request.commentId },
contextSnapshot: { issueId: f.issueId, source: "chat:slack", wakeCommentId: request.commentId },
requestedByActorType: "user", requestedByActorId: request.requestedByActorId,
durableChatRequest: request,
});
for (let i = 0; i < 3; i++) expect(await wake()).toBeNull();
const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({
id: request.id, idempotencyKey: request.idempotencyKey, requestedAt: request.requestedAt,
status: "deferred_issue_execution", runId: null, requestedByActorId: request.requestedByActorId,
payload: { _paperclipWakeContext: { source: "chat:slack", wakeCommentIds: [request.commentId] } },
});
expect(f.authorize).toHaveBeenCalledTimes(1);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.wakeupRequestId, request.id))).toHaveLength(0);
await clearHold();
const generic = await f.heartbeat.wakeup(f.agentId, {
source: "on_demand", triggerDetail: "manual", reason: "issue_resumed",
payload: { issueId: f.issueId }, requestedByActorType: "user", requestedByActorId: "board-user",
});
expect(generic?.status).toBe("queued");
expect(generic?.contextSnapshot?.wakeCommentIds).toBeUndefined();
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, request.id)))[0]).toMatchObject({
status: "deferred_issue_execution", runId: null,
});
});
async function retryFixture(deferred = false) {
const f = await fixture(deferred);
const applicationId = randomUUID(),
connectionId = randomUUID(),
endpointId = randomUUID();
const failedRunId = randomUUID(),
actionId = randomUUID();
const commentIds = [randomUUID(), randomUUID()];
const taskKey = `D${f.companyId.slice(0, 7)}-1`;
await db
.update(issues)
.set({ identifier: taskKey })
.where(eq(issues.id, f.issueId));
await db.insert(toolApplications).values({
id: applicationId,
companyId: f.companyId,
name: "Retry authority",
type: "chat",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: f.companyId,
applicationId,
name: "Retry",
uid: `retry-${connectionId}`,
connectionPurpose: "channel",
transport: "chat_sdk",
status: "active",
});
await db.insert(chatEndpoints).values({
id: endpointId,
companyId: f.companyId,
connectionId,
provider: "slack",
publicId: randomUUID(),
assignedAgentId: f.agentId,
status: "active",
});
await db.insert(heartbeatRuns).values({
id: failedRunId,
companyId: f.companyId,
agentId: f.agentId,
status: "failed",
finishedAt: new Date(),
contextSnapshot: {
issueId: f.issueId,
taskKey,
source: "chat:slack",
wakeCommentIds: commentIds,
},
});
await db.insert(issueComments).values(
commentIds.map((id) => ({
id,
companyId: f.companyId,
issueId: f.issueId,
authorUserId: "board-user",
body: "Exact original request",
})),
);
await db.insert(chatActions).values({
id: actionId,
companyId: f.companyId,
endpointId,
kind: "failed_run_retry",
providerActionId: `failed_run_retry:${failedRunId}`,
status: "issued",
payload: { issueId: f.issueId, failedRunId },
});
const context = {
issueId: f.issueId,
source: "chat:slack",
taskKey,
wakeCommentId: commentIds[1],
wakeCommentIds: commentIds,
retryOfRunId: failedRunId,
chatFailedRunRetry: { version: 1, actionId, failedRunId },
};
const request = createDurableChatWakeupRequest({
...f.request(commentIds[1]),
id: actionId,
failedRunRetry: { failedRunId },
});
const authority = vi.fn(
async (
_tx: unknown,
input: { contextSnapshot: Record<string, unknown>; phase: string },
) => {
expect(input.contextSnapshot).toMatchObject(context);
},
);
const register = () => {
const dispose = registerFailedChatRunRetryAuthority(db, authority);
unregisterAuthorities.push(dispose);
return dispose;
};
const wake = (options: Record<string, unknown> = {}) =>
f.heartbeat.wakeup(f.agentId, {
source: "assignment",
triggerDetail: "system",
reason: "retry_failed_chat_run",
payload: { ...context },
contextSnapshot: { ...context },
requestedByActorType: "user",
requestedByActorId: "board-user",
durableChatRequest: request,
allowRunCoalescing: true,
...options,
});
return {
...f,
failedRunId,
actionId,
context,
commentIds,
request,
authority,
register,
wake,
};
}
it("fails closed for a persisted retry when its live authority is unavailable", async () => {
const f = await retryFixture();
await expect(f.wake()).rejects.toBeInstanceOf(
FailedChatRunRetryAuthorizationError,
);
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, f.actionId)),
).toEqual([]);
});
it("does not turn a forged retry selector into authority", async () => {
const f = await fixture();
await expect(
f.heartbeat.wakeup(f.agentId, {
payload: { issueId: f.issueId },
contextSnapshot: {
chatFailedRunRetry: {
version: 1,
actionId: randomUUID(),
failedRunId: randomUUID(),
},
},
requestedByActorType: "user",
requestedByActorId: "board-user",
}),
).rejects.toBeInstanceOf(FailedChatRunRetryAuthorizationError);
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId)),
).toEqual([]);
});
it("preserves exact retry provenance and a deeply frozen server-only selector", async () => {
const f = await retryFixture();
f.register();
expect(Object.isFrozen(f.request.failedRunRetry)).toBe(true);
const run = await f.wake();
expect(run).toMatchObject({
status: "queued",
retryOfRunId: f.failedRunId,
wakeupRequestId: f.actionId,
contextSnapshot: f.context,
});
expect(f.authority).toHaveBeenCalledTimes(1);
expect(f.authority.mock.calls[0][1]).toMatchObject({ phase: "admission" });
await f.wake();
expect(f.authority).toHaveBeenCalledTimes(1);
});
it("cannot coalesce an exact retry into active or deferred ordinary work", async () => {
const f = await retryFixture(true);
const ordinary = f.request;
await queueIssueAssignmentWakeup({
heartbeat: f.heartbeat,
issue: {
id: f.issueId,
assigneeAgentId: f.agentId,
status: "in_progress",
},
reason: "External chat message received",
mutation: "chat_message_received",
contextSource: "chat:slack",
requestedByActorType: "user",
requestedByActorId: "board-user",
wakeCommentId: f.commentIds[0],
durableChatRequest: createDurableChatWakeupRequest({
...ordinary,
id: randomUUID(),
commentId: f.commentIds[0],
failedRunRetry: undefined,
}),
rethrowOnError: true,
});
f.register();
await f.wake();
const receipts = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId));
expect(receipts).toHaveLength(2);
expect(
receipts.every(
(row) =>
row.status === "deferred_issue_execution" && row.coalescedCount === 0,
),
).toBe(true);
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.wakeupRequestId, f.actionId)),
).toEqual([]);
});
it("rediscovers a retry from durable provenance even if its hint was stripped", async () => {
const f = await retryFixture();
const { chatFailedRunRetry: _selector, ...contextSnapshot } = f.context;
await expect(
authorizeFailedChatRunRetryWake(db, db, {
phase: "execution",
wakeupRequestId: f.actionId,
companyId: f.companyId,
agentId: f.agentId,
issueId: f.issueId,
contextSnapshot,
}),
).rejects.toBeInstanceOf(FailedChatRunRetryAuthorizationError);
});
it("an older service disposer cannot remove a newer retry authority", async () => {
const f = await retryFixture();
const oldDispose = f.register();
const replacement = vi.fn(async () => {});
unregisterAuthorities.push(
registerFailedChatRunRetryAuthority(db, replacement),
);
oldDispose();
await f.wake();
expect(replacement).toHaveBeenCalledTimes(1);
expect(f.authority).not.toHaveBeenCalled();
});
it.each([false, true])("cancels a revoked deferred retry without reopening its batch (execution hold=%s)", async (held) => {
const f = await retryFixture(true);
const clearHold = held ? await executionHold(f) : null;
f.register();
await f.wake();
await clearHold?.();
f.authority.mockImplementation(async (_tx, input) => {
if (input.phase === "promotion")
throw conflict("Current chat access was revoked");
});
await db
.update(issues)
.set({ status: "done" })
.where(eq(issues.id, f.issueId));
await f.heartbeat.cancelRun(f.activeRunId, "Fixture predecessor finished", {
suppressImmediateRecovery: true,
});
await f.heartbeat.drainActiveRunExecutions();
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, f.actionId));
expect(receipt).toMatchObject({ status: "cancelled", runId: null });
expect(receipt.payload).toMatchObject({ wakeCommentIds: f.commentIds });
const [issue] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
expect(issue).toMatchObject({ status: "done", executionRunId: null });
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.wakeupRequestId, f.actionId)),
).toEqual([]);
expect(f.authority.mock.calls.map((call) => call[1].phase)).toEqual([
"admission",
"promotion",
]);
});
it("denies a queued retry at execution when current authority is revoked", async () => {
const f = await retryFixture();
f.register();
const queued = await f.wake();
f.authority.mockImplementation(async (_tx, input) => {
if (input.phase === "execution")
throw conflict("Current chat access was revoked", {
code: "chat_failed_run_retry_not_authorized",
});
});
await f.heartbeat.cancelRun(
f.activeRunId,
"Fixture execution slot released",
{ suppressImmediateRecovery: true },
);
await f.heartbeat.drainActiveRunExecutions();
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, queued!.id));
expect(run).toMatchObject({
status: "failed",
retryOfRunId: f.failedRunId,
nativeSessionId: null,
errorCode: "chat_failed_run_retry_not_authorized",
});
expect(run.error).toContain("Current chat access was revoked");
const [issue] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
expect(issue).toMatchObject({ status: "blocked", executionRunId: null });
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(3);
expect(f.authority.mock.calls.map((call) => call[1].phase)).toEqual([
"admission",
"execution",
]);
});
it.each([false, true])("preserves the exact retry column and comment batch during promotion (execution hold=%s)", async (held) => {
const f = await retryFixture(true);
const clearHold = held ? await executionHold(f) : null;
f.register();
await f.wake();
await clearHold?.();
// A separate fixture-owned run occupies the agent slot after the issue's
// predecessor releases it, so this asserts promotion before execution.
const blockerId = randomUUID();
await db.insert(heartbeatRuns).values({
id: blockerId,
companyId: f.companyId,
agentId: f.agentId,
status: "running",
contextSnapshot: { issueId: randomUUID() },
});
runningProcesses.set(blockerId, {
child: {} as never,
graceSec: 0,
processGroupId: null,
});
liveRunIds.add(blockerId);
await f.heartbeat.cancelRun(f.activeRunId, "Fixture predecessor finished", {
suppressImmediateRecovery: true,
});
await f.heartbeat.drainActiveRunExecutions();
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.wakeupRequestId, f.actionId));
expect(run).toMatchObject({
status: "queued",
retryOfRunId: f.failedRunId,
contextSnapshot: f.context,
});
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, f.actionId));
expect(receipt).toMatchObject({ status: "queued", runId: run.id });
expect(f.authority.mock.calls.map((call) => call[1].phase)).toEqual([
"admission",
"promotion",
]);
});
it.each([false, true])(
"rechecks current retry authority immediately before dispatch (revoked=%s)",
async (revoked) => {
const f = await retryFixture();
await db
.update(agents)
.set({ adapterType: "durable_chat_retry_test" })
.where(eq(agents.id, f.agentId));
f.register();
const queued = await f.wake();
let executionChecks = 0;
f.authority.mockImplementation(async (_tx, input) => {
if (input.phase === "execution" && ++executionChecks === 2 && revoked) {
throw conflict("Current chat access was revoked before dispatch", {
code: "chat_failed_run_retry_not_authorized",
});
}
});
await f.heartbeat.cancelRun(
f.activeRunId,
"Fixture execution slot released",
{ suppressImmediateRecovery: true },
);
await f.heartbeat.drainActiveRunExecutions();
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, queued!.id));
expect(executionChecks).toBe(2);
expect(execute).toHaveBeenCalledTimes(revoked ? 0 : 1);
expect(run).toMatchObject({
status: revoked ? "failed" : "succeeded",
retryOfRunId: f.failedRunId,
nativeSessionId: null,
});
if (revoked)
expect(run.errorCode).toBe("chat_failed_run_retry_not_authorized");
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(3);
expect(f.authority.mock.calls.map((call) => call[1].phase)).toEqual([
"admission",
"execution",
"execution",
]);
},
);
it.each([
{ forceFreshSession: true },
{ resumeFromRunId: randomUUID() },
{ resumeSessionParams: { sessionId: "unrelated-provider-session" } },
{ resumeSessionDisplayId: "unrelated-provider-session" },
])(
"does not admit routing overrides on an exact retry (%j)",
async (override) => {
const f = await retryFixture();
f.register();
await expect(
f.wake({ contextSnapshot: { ...f.context, ...override } }),
).rejects.toBeInstanceOf(FailedChatRunRetryAuthorizationError);
expect(f.authority).not.toHaveBeenCalled();
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, f.actionId)),
).toEqual([]);
},
);
it.each([
{ withDeferredInput: false, sourceKind: "inbound_wakeup" },
{ withDeferredInput: true, sourceKind: "inbound_wakeup" },
{ withDeferredInput: false, sourceKind: "unrelated_action" },
{ withDeferredInput: false, sourceKind: "missing" },
].flatMap((scope) => [false, true].map((preProvider) => ({ ...scope, preProvider }))))(
"fences generic recovery by actual original chat ownership ($sourceKind, deferred=$withDeferredInput, pre-provider=$preProvider)",
async ({ withDeferredInput, sourceKind, preProvider }) => {
const f = await retryFixture();
await db
.update(agents)
.set({ adapterType: "durable_chat_retry_test" })
.where(eq(agents.id, f.agentId));
await db
.update(chatActions)
.set({ kind: sourceKind, payload: { issueId: f.issueId } })
.where(eq(chatActions.id, f.actionId));
if (sourceKind === "missing")
await db.delete(chatActions).where(eq(chatActions.id, f.actionId));
const genericRecoveryExpected = preProvider && sourceKind !== "inbound_wakeup";
const nextExecutionExpected = preProvider && (withDeferredInput || genericRecoveryExpected);
const { failedRunRetry: _retry, ...ordinaryRequest } = f.request;
const wakeOriginal = (id: string, commentId: string) =>
f.heartbeat.wakeup(f.agentId, {
source: "assignment",
triggerDetail: "system",
reason: "chat_message_received",
requestedByActorType: "user",
requestedByActorId: "board-user",
payload: {
issueId: f.issueId,
wakeCommentId: commentId,
wakeCommentIds: [commentId],
},
contextSnapshot: {
issueId: f.issueId,
taskKey: f.context.taskKey,
source: "chat:slack",
wakeCommentId: commentId,
wakeCommentIds: [commentId],
},
durableChatRequest: createDurableChatWakeupRequest({
...ordinaryRequest,
id,
commentId,
}),
allowRunCoalescing: false,
});
if (preProvider) {
execute.mockResolvedValueOnce({
exitCode: 1, signal: null, timedOut: false,
summary: "Fixture failed before provider work",
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
});
} else {
execute.mockRejectedValueOnce(new Error("Original chat provider outcome is unknown"));
}
const original = await wakeOriginal(f.actionId, f.commentIds[1]);
const deferredId = randomUUID();
if (withDeferredInput) {
const [owner] = await db
.select()
.from(chatActions)
.where(eq(chatActions.id, f.actionId));
await db.insert(chatActions).values({
...owner,
id: deferredId,
providerActionId: `wakeup:${deferredId}`,
});
expect(await wakeOriginal(deferredId, f.commentIds[0])).toBeNull();
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, deferredId));
expect(receipt.status).toBe("deferred_issue_execution");
}
await f.heartbeat.cancelRun(
f.activeRunId,
"Fixture execution slot released",
{ suppressImmediateRecovery: true },
);
await f.heartbeat.drainActiveRunExecutions();
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, original!.id));
expect(run).toMatchObject({
status: "failed",
retryOfRunId: null,
wakeupRequestId: f.actionId,
});
expect(execute).toHaveBeenCalledTimes(
nextExecutionExpected ? 2 : 1,
);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId));
expect(runs).toHaveLength(
nextExecutionExpected ? 4 : 3,
);
expect(
runs.some(
(entry) =>
(entry.contextSnapshot as Record<string, unknown>)?.source ===
"issue.continuation_recovery",
),
).toBe(genericRecoveryExpected);
const [issue] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
expect(issue).toMatchObject({
status:
nextExecutionExpected ? "done" : preProvider ? "blocked" : "in_progress",
executionRunId: null,
});
if (withDeferredInput) {
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, deferredId));
const next = runs.find((entry) => entry.id === receipt.runId);
if (!preProvider) {
// Unknown earlier effects require reconciliation. Preserve the
// separate user's durable cause, without dispatching it on this
// cancellation/failure path or relabeling it as an automatic retry.
expect(receipt).toMatchObject({ status: "deferred_issue_execution", runId: null });
expect(next).toBeUndefined();
return;
}
expect(next).toMatchObject({
status: "succeeded",
retryOfRunId: null,
wakeupRequestId: deferredId,
contextSnapshot: {
source: "chat:slack",
wakeCommentIds: [f.commentIds[0]],
},
});
}
},
);
it("keeps committed-response authority exact-Db scoped and separate from retry hints", async () => {
const input = {
companyId: randomUUID(),
issueId: randomUUID(),
agentId: randomUUID(),
runId: randomUUID(),
resultId: randomUUID(),
};
const otherDb = {} as typeof db;
const authority = vi.fn(async () => undefined);
const dispose = registerCommittedChatResponseAuthority(db, authority);
try {
await expect(
authorizeCommittedChatResponse(otherDb, db, input),
).rejects.toBeInstanceOf(CommittedChatResponseAuthorizationError);
expect(authority).not.toHaveBeenCalled();
await authorizeCommittedChatResponse(db, db, input);
expect(authority).toHaveBeenCalledExactlyOnceWith(db, input);
} finally {
dispose();
}
await expect(
authorizeCommittedChatResponse(db, db, input),
).rejects.toBeInstanceOf(CommittedChatResponseAuthorizationError);
});
it("preserves the current committed-response authority and exact transient failure", async () => {
const input = {
companyId: randomUUID(),
issueId: randomUUID(),
agentId: randomUUID(),
runId: randomUUID(),
resultId: randomUUID(),
};
const first = registerCommittedChatResponseAuthority(
db,
async () => undefined,
);
const storageFailure = new Error("temporary source storage failure");
const second = registerCommittedChatResponseAuthority(db, async () => {
throw storageFailure;
});
try {
first();
await expect(authorizeCommittedChatResponse(db, db, input)).rejects.toBe(
storageFailure,
);
} finally {
second();
}
});
it("does not discard a deferred retry on transient authority storage failure", async () => {
const f = await retryFixture(true);
f.register();
await f.wake();
const failure = new Error("Fixture authorization store unavailable");
f.authority.mockRejectedValueOnce(failure);
await expect(
f.heartbeat.cancelRun(f.activeRunId, "Fixture predecessor finished", {
suppressImmediateRecovery: true,
}),
).rejects.toBe(failure);
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, f.actionId));
expect(receipt).toMatchObject({
status: "deferred_issue_execution",
runId: null,
});
expect(receipt.payload).toMatchObject({ wakeCommentIds: f.commentIds });
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.wakeupRequestId, f.actionId)),
).toEqual([]);
});
it("retries and competing workers create one queued receipt and one run", async () => {
const f = await fixture();
const request = f.request();
await Promise.all([f.wake(request), f.wake(request)]);
await f.wake(request);
const receipts = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, request.id));
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({
status: "queued",
idempotencyKey: request.idempotencyKey,
});
expect(
await db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, f.agentId),
eq(heartbeatRuns.wakeupRequestId, request.id),
),
),
).toHaveLength(1);
expect(f.authorize).toHaveBeenCalledTimes(1);
});
it("records a stable receipt when merging into a deferred wake and never merges the replay twice", async () => {
const f = await fixture(true);
const first = f.request(),
second = f.request();
await f.wake(first);
await f.wake(second);
await f.wake(second);
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId));
expect(rows).toHaveLength(2);
expect(rows.find((row) => row.id === first.id)).toMatchObject({
status: "deferred_issue_execution",
coalescedCount: 1,
});
expect(rows.find((row) => row.id === second.id)).toMatchObject({
status: "coalesced",
payload: { coalescedIntoWakeupRequestId: first.id },
});
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(1);
});
it("keeps different actors in separate deferred receipts", async () => {
const f = await fixture(true);
await f.wake(f.request());
await f.wake(f.request(randomUUID(), "another-user"));
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId));
expect(rows).toHaveLength(2);
expect(
rows.every(
(row) =>
row.status === "deferred_issue_execution" && row.coalescedCount === 0,
),
).toBe(true);
});
it("records queued-run coalescence once with the stable receipt ID", async () => {
const f = await fixture();
const first = f.request(),
second = f.request();
await f.wake(first);
const [firstReceipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, first.id));
await db
.update(issues)
.set({
executionRunId: firstReceipt.runId,
executionAgentNameKey: "maya",
executionLockedAt: new Date(),
})
.where(eq(issues.id, f.issueId));
await f.wake(second);
await f.wake(second);
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, second.id));
expect(receipt).toMatchObject({
status: "coalesced",
runId: firstReceipt.runId,
coalescedCount: 1,
});
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(2);
});
it("rejects a stable receipt ID reused for a different authenticated actor", async () => {
const f = await fixture();
const first = f.request();
await f.wake(first);
const conflicting = createDurableChatWakeupRequest({
...first,
requestedByActorId: "different-user",
});
await expect(f.wake(conflicting)).rejects.toThrow(
"chat_inbound_wakeup_receipt_conflict",
);
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId)),
).toHaveLength(1);
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(2);
});
it("never readmits or dispatches a cancelled receipt on replay", async () => {
const f = await fixture();
const request = f.request();
await f.wake(request);
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, request.id));
await db
.update(agentWakeupRequests)
.set({ status: "cancelled" })
.where(eq(agentWakeupRequests.id, request.id));
await db
.update(heartbeatRuns)
.set({ status: "cancelled", finishedAt: new Date() })
.where(eq(heartbeatRuns.id, receipt.runId!));
await f.wake(request);
expect(f.authorize).toHaveBeenCalledTimes(1);
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(2);
});
it("does not accept a JSON copy of the internal scheduling capability", async () => {
const f = await fixture();
const request = f.request();
await expect(f.wake(JSON.parse(JSON.stringify(request)))).rejects.toThrow(
"chat_inbound_wakeup_binding_denied",
);
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId)),
).toEqual([]);
});
it("rechecks current authorization in the scheduling transaction", async () => {
const f = await fixture();
f.authorize.mockRejectedValueOnce(new Error("reach revoked"));
await expect(f.wake(f.request())).rejects.toThrow("reach revoked");
expect(
await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId)),
).toEqual([]);
});
it.each([
{ provider: "slack", state: "active", callerMarkers: false },
{ provider: "telegram", state: "completed", callerMarkers: false },
{ provider: "slack", state: "active", callerMarkers: true },
] as const)(
"rejects generic failed-chat retry before admission ($provider/$state; caller markers: $callerMarkers)",
async ({ provider, state, callerMarkers }) => {
// Keep the fixture-owned unrelated run occupying the only execution slot.
// This exercises real heartbeat admission without starting an adapter.
const f = await fixture();
const applicationId = randomUUID();
const connectionId = randomUUID();
const endpointId = randomUUID();
const conversationId = randomUUID();
const originalCommentId = randomUUID();
const failedCommentId = randomUUID();
const taskKey = `D${f.companyId.slice(0, 7)}-1`;
await db
.update(issues)
.set({
identifier: taskKey,
description: "Original request A: describe the original photo.",
})
.where(eq(issues.id, f.issueId));
await db.insert(toolApplications).values({
id: applicationId,
companyId: f.companyId,
name: "Failed chat retry",
type: "chat",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: f.companyId,
applicationId,
name: `${provider} failed chat retry`,
uid: `chat-retry-${connectionId}`,
connectionPurpose: "channel",
transport: "chat_sdk",
enabled: true,
status: "active",
});
await db.insert(chatEndpoints).values({
id: endpointId,
companyId: f.companyId,
connectionId,
provider,
publicId: randomUUID(),
assignedAgentId: f.agentId,
status: "active",
});
await db.insert(chatConversations).values({
id: conversationId,
companyId: f.companyId,
endpointId,
issueId: f.issueId,
externalConversationId: `${provider}-retry-dm`,
externalThreadId: `${provider}-retry-thread`,
externalLabel: `${provider} retry fixture`,
sessionGeneration: 1,
isDirectMessage: true,
state: "active",
});
await db.insert(issueComments).values([
{
id: originalCommentId,
companyId: f.companyId,
issueId: f.issueId,
authorUserId: "board-user",
body: "Original request A: describe the original photo.",
},
{
id: failedCommentId,
companyId: f.companyId,
issueId: f.issueId,
authorUserId: "board-user",
body: "Later request B: explain the new queue behavior, not the photo.",
},
]);
await db.insert(chatMessageLinks).values(
[originalCommentId, failedCommentId].map((commentId, index) => ({
companyId: f.companyId,
endpointId,
conversationId,
commentId,
providerMessageId: `${provider}-retry-${index}`,
direction: "inbound",
})),
);
const request = f.request(failedCommentId);
await queueIssueAssignmentWakeup({
heartbeat: f.heartbeat,
issue: {
id: f.issueId,
assigneeAgentId: f.agentId,
status: "in_progress",
},
reason: "External chat message received",
mutation: "chat_message_received",
contextSource: `chat:${provider}`,
taskKey,
requestedByActorType: request.requestedByActorType,
requestedByActorId: request.requestedByActorId,
wakeCommentId: failedCommentId,
durableChatRequest: request,
rethrowOnError: true,
});
const [admitted] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.wakeupRequestId, request.id));
expect(admitted).toMatchObject({
status: "queued",
contextSnapshot: {
taskKey,
source: `chat:${provider}`,
wakeCommentId: failedCommentId,
wakeCommentIds: [failedCommentId],
},
});
// Simulate a transient execution failure, not an integrity fault or a
// manual edit to any live runner root. The admitted request stays intact.
await db
.update(heartbeatRuns)
.set({ status: "failed", finishedAt: new Date() })
.where(eq(heartbeatRuns.id, admitted.id));
await db
.update(agentWakeupRequests)
.set({ status: "failed", finishedAt: new Date() })
.where(eq(agentWakeupRequests.id, request.id));
await db
.update(issues)
.set({
status: "blocked",
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
})
.where(eq(issues.id, f.issueId));
if (state === "completed") {
await db
.update(chatConversations)
.set({ state: "completed" })
.where(eq(chatConversations.id, conversationId));
}
const snapshot = async () => ({
runs: await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId))
.orderBy(heartbeatRuns.id),
receipts: await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId))
.orderBy(agentWakeupRequests.id),
issue: await db.select().from(issues).where(eq(issues.id, f.issueId)),
conversation: await db
.select()
.from(chatConversations)
.where(eq(chatConversations.id, conversationId)),
publications: await db
.select()
.from(chatPublications)
.where(eq(chatPublications.conversationId, conversationId)),
});
const before = await snapshot();
// This is the existing IssueDetail/Inbox generic wakeup API, deliberately
// lacking a failed-run ID. It must not silently retry A in a UUID-keyed
// session after losing B's exact chat comment and admission authority.
const rejection = await f.heartbeat
.wakeup(f.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload: {
issueId: f.issueId,
...(callerMarkers
? {
retryOfRunId: admitted.id,
taskKey,
commentId: failedCommentId,
}
: {}),
},
...(callerMarkers
? {
contextSnapshot: {
source: `chat:${provider}`,
retryOfRunId: admitted.id,
wakeCommentId: failedCommentId,
wakeCommentIds: [failedCommentId],
},
}
: {}),
requestedByActorType: "user",
requestedByActorId: "board-user",
})
.then(
() => null,
(error: unknown) => error,
);
expect.soft(rejection).toMatchObject({
status: 409,
details: { code: "chat_failed_run_retry_requires_authorized_context" },
});
const after = await snapshot();
expect.soft(after.receipts).toEqual(before.receipts);
expect.soft(after.runs).toEqual(before.runs);
expect.soft(after.issue).toEqual(before.issue);
expect.soft(after.conversation).toEqual(before.conversation);
expect.soft(after.publications).toEqual(before.publications);
expect(f.authorize).toHaveBeenCalledTimes(1);
},
);
it("preserves ordinary non-chat manual retry admission", async () => {
const f = await fixture();
const retry = await f.heartbeat.wakeup(f.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload: { issueId: f.issueId },
requestedByActorType: "user",
requestedByActorId: "board-user",
});
expect(retry).toMatchObject({
status: "queued",
contextSnapshot: {
issueId: f.issueId,
taskKey: f.issueId,
wakeReason: "retry_failed_run",
},
});
const receipts = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, f.agentId));
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({ status: "queued", runId: retry!.id });
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, f.agentId)),
).toHaveLength(2);
});
});