From 88b7bbb5e248e9dde5aa7671b080f8508ef1865e Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 07:29:15 -0500
Subject: [PATCH 01/23] Stabilize task lifecycle browser fixtures (#15878)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip manages agents and their tasks.
> - Browser and runner tests check task recovery and user actions.
> - Some fixtures act before a prior request or run has settled.
> - Those races can report a product failure before the intended
assertion runs.
> - This PR binds each check to the exact request, run, or geometry
snapshot it needs.
> - The tests retain their existing budgets and negative assertions.
## Linked Issues or Issue Description
Refs: #15813, #15804.
The draft stack is master → [fixture prerequisite
#15878](https://github.com/paperclipai/paperclip/pull/15878) → [runtime
prerequisite
#15880](https://github.com/paperclipai/paperclip/pull/15880) → [Boat
core #15813](https://github.com/paperclipai/paperclip/pull/15813) → [UI
#15804](https://github.com/paperclipai/paperclip/pull/15804). GitHub
confirms 5, 63, 97, and 75 changed files respectively, each below the
100-file limit. Existing commits and PR URLs remain intact. [Historical
evidence](https://github.com/paperclipai/paperclip/tree/5909bfac9fa41405beb3fe8a65253dc28ba52835/doc/plans/assets/2026-10-10-boat)
remains pinned to its original commit; current proof files stay with the
UI PR.
This is the test-only prerequisite extracted from the experimental Boat
work. It contains five existing test-file changes. It does not add the
Boat adapter or change task admission.
## What Changed
- Wait for the exact signoff fixture run and each replacement run to
succeed before applying the next stage decision.
- Wait for the inbox Retry response for the exact failed run before
navigating away.
- Compare task planning time with the first run that actually started,
instead of assuming the first list item started.
- Read the touch target, container, and scroll position in one browser
evaluation. Keep strict containment and native gesture checks.
- Bind the native recovery fixture to its disposable fake Codex
executable so installed provider binaries cannot override it.
## Verification
Draft branch: `codex/boat-fixture-prerequisite`, commit
`e318850909c0777565b43644a4d8114c6006da60`. Base: master
`e733bddc45e00efb1819fffa8677f7b0d07802c5`. Five changed files.
The isolated fixture-plus-runtime stack passed 24 browser cases. Six
legacy recovery cases could not reach their assertions because the
fixture requires embedded PostgreSQL metadata. A normal embedded
PostgreSQL attempt then failed during cluster setup on this host. These
are not six passing cases. No test seam, timeout increase, or
product-database fixture was used.
The fixture-only commit has not yet had a separate complete local
browser run. All six legacy cases subsequently passed on this exact
fixture commit in PR CI browser shard 8 (20/20 total, job114470999277).
The original local setup failures remain recorded. The full-stack
typecheck/build results belong to the assembled fixture-plus-runtime
tree and are not claimed as independent qualification of this smaller
commit.
Review the exact run/request filters and the unchanged rejection
assertions. Run the four changed Playwright specs and the
native-session-resume suite through the repository's normal fixture
setup. At 13:03:24 UTC, this exact head had 53 successful checks and two
skips, with no pending checks or failures. All eight browser shards
passed. Greptile is 5/5 on the exact head with no unresolved threads.
[Exact-head legacy browser
proof](https://github.com/paperclipai/paperclip/blob/9eacca2c5321bb63f061eef4bb775b6951d5846d/doc/plans/assets/2026-10-10-boat/evidence/prerequisite-legacy-browser-ci-proof.json).
Feature acceptance stays in the downstream report. Deployment29 verified
`05f3725af838b5a074707df1d6ed1e193bdfe49e` at 12:23:23.944 UTC. Native
Claude cold execution passed at 12:39:57.782 after 14m56s of first-use
preparation. Warm continuation passed at 12:42:09.943 with the same
owner, boot and session identities. Normal retirement and archive
followed. The ordinary saved post-idle turn then failed: its primary
attempt reported the closed `LINK_COUNT` module fence at
`turn_ownership_verified` (3,711ms), and same-run recovery settled
failed at 12:52:09 with identity/authority errors. Natural archive
completed at 12:52:21. Numeric link counts and their direction are not
yet proven. A narrower failure-only diagnostic and a bounded read-only
probe are under review; neither establishes a fix for this live failure.
Prior staging27/28 failures remain preserved. Local complete lifecycle
passes and staging Codex/computer/HMR passes remain separate
source-bound evidence. No session, quarantine, provider stop or
migration history was reset. These live observations do not qualify this
prerequisite by themselves.
## Risks
- Waiting for the wrong run could hide a task race. The fixtures match
task, agent, and replacement-run identity before proceeding.
- A geometry helper could weaken touch coverage. This change preserves
containment and gesture assertions and only makes their inputs one
snapshot.
- The six legacy browser cases passed in exact-head CI; their local
PostgreSQL setup limitation remains recorded. No live Boat acceptance
result follows from this test-only change.
Documentation impact: no product command or behavior changes. This
description records fixture scope and validation limits.
## Model Used
OpenAI Codex (GPT-6 family) assisted with code, test execution, and
review. The host does not expose the exact primary model ID or context
window. The work used tools for source inspection and local execution.
Prior architecture work used `gpt-6-astra`; an independent design review
used `gpt-6.1-sol`.
## 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 issue id or instance-derived details
- [ ] 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
---
.../native-session-resume.test.ts | 8 +++++
tests/e2e/agent-chat-projects.spec.ts | 13 +++++--
tests/e2e/legacy-failure-continuation.spec.ts | 14 +++++++-
tests/e2e/new-task-picker-touch.spec.ts | 36 +++++++++++++++++--
tests/e2e/signoff-policy.spec.ts | 31 ++++++++++++++++
5 files changed, 95 insertions(+), 7 deletions(-)
diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts
index d486db139f4..52f2f12606c 100644
--- a/server/src/services/native-runtime/native-session-resume.test.ts
+++ b/server/src/services/native-runtime/native-session-resume.test.ts
@@ -743,6 +743,13 @@ const recoveryFakeCodex = resolve(
runnerDiagnostics.push(chunk.slice(-4_096));
if (runnerDiagnostics.length > 32) runnerDiagnostics.shift();
};
+ // The production resolver prefers installed Codex over PATH. Explicitly
+ // bind this disposable fixture so it can never invoke a real provider.
+ const codexCommandResolver = await vi.importActual<{ resolveCodexCommand: () => string }>(
+ "../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js",
+ );
+ const resolveFixtureCodex = vi.spyOn(codexCommandResolver, "resolveCodexCommand")
+ .mockReturnValue(join(bin, "codex"));
process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase;
try {
await Promise.all(
@@ -1227,6 +1234,7 @@ const recoveryFakeCodex = resolve(
Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })),
));
const closeFailures = closed.filter((result) => result.status === "rejected");
+ resolveFixtureCodex.mockRestore();
runnerPrpWebSocketInternals.resetForTests();
server.closeAllConnections();
await new Promise((done) => server.close(() => done()));
diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts
index d4979449e37..c8fcdc2345e 100644
--- a/tests/e2e/agent-chat-projects.spec.ts
+++ b/tests/e2e/agent-chat-projects.spec.ts
@@ -124,12 +124,19 @@ test("split handoff commits relevant plans before execution and never creates ch
const runs = await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
);
- const run = runs.find(
+ const taskRuns = runs.filter(
(run: any) => run.contextSnapshot?.issueId === task.id,
);
- expect(run).toBeTruthy();
+ expect(taskRuns.length).toBeGreaterThan(0);
+ const startedAt = taskRuns
+ .filter((run: any) => run.startedAt != null)
+ .map((run: any) => Date.parse(run.startedAt));
+ // A newer queued/cancelled run need not have started. Preserve the
+ // invariant against the first actual execution, not list ordering.
+ expect(startedAt.length).toBeGreaterThan(0);
+ expect(startedAt.every(Number.isFinite)).toBe(true);
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
- Date.parse(run.startedAt),
+ Math.min(...startedAt),
);
}
expect(
diff --git a/tests/e2e/legacy-failure-continuation.spec.ts b/tests/e2e/legacy-failure-continuation.spec.ts
index dc7875e862b..6645935cbab 100644
--- a/tests/e2e/legacy-failure-continuation.spec.ts
+++ b/tests/e2e/legacy-failure-continuation.spec.ts
@@ -99,9 +99,21 @@ for (const action of ["task_retry", "thread_retry", "inbox_retry", "message", "q
} else if (action === "message") {
await page.getByRole("textbox", { name: "editable markdown" }).fill("Please continue the pending follow-up.");
await page.getByRole("button", { name: "Send", exact: true }).click();
+ } else if (action === "inbox_retry") {
+ // Navigation can cancel the mutation before its request is sent.
+ const [response] = await Promise.all([
+ page.waitForResponse((response) => {
+ const request = response.request();
+ return request.method() === "POST"
+ && new URL(response.url()).pathname.endsWith(`/agents/${agent.id}/wakeup`)
+ && request.postDataJSON()?.failedRunId === sourceRunId;
+ }),
+ page.getByRole("button", { name: "Retry", exact: true }).click(),
+ ]);
+ expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true);
+ await page.goto(taskUrl);
} else {
await page.getByRole("button", { name: action === "thread_retry" ? "Try again" : "Retry", exact: true }).click();
- if (action === "inbox_retry") await page.goto(taskUrl);
}
await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 45_000 });
await expect(page.getByRole("status", { name: "Task recovery" })).toHaveCount(0);
diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts
index b48d7084d67..6804125114e 100644
--- a/tests/e2e/new-task-picker-touch.spec.ts
+++ b/tests/e2e/new-task-picker-touch.spec.ts
@@ -21,13 +21,23 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB
}).toBeLessThanOrEqual(page.viewportSize()!.height);
expect((await picker.boundingBox())!.y).toBeGreaterThanOrEqual(0);
const session = await page.context().newCDPSession(page);
+ const targetHandle = await last.elementHandle();
+ expect(targetHandle).not.toBeNull();
try {
expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
for (let attempt = 0; attempt < 20; attempt += 1) {
- const bounds = (await list.boundingBox())!;
- const target = (await last.boundingBox())!;
+ // Momentum can finish between protocol round trips. Observe geometry and
+ // scrollTop together so a stale target cannot trigger a swipe at the end.
+ const { bounds, target, before } = await list.evaluate((element, option) => {
+ const container = element.getBoundingClientRect();
+ const item = option!.getBoundingClientRect();
+ return {
+ bounds: { x: container.x, y: container.y, width: container.width, height: container.height },
+ target: { y: item.y, height: item.height },
+ before: element.scrollTop,
+ };
+ }, targetHandle);
if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break;
- const before = await list.evaluate((element) => element.scrollTop);
const x = bounds.x + bounds.width / 2;
const y = bounds.y + bounds.height - 20;
const distance = Math.min(180, bounds.height - 40);
@@ -49,6 +59,7 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB
expect(target.y).toBeGreaterThanOrEqual(bounds.y);
expect(target.y + target.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1);
} finally {
+ await targetHandle?.dispose();
await session.detach();
}
return last;
@@ -56,6 +67,25 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
+test("touch scroll observation stays valid when momentum finishes between browser reads", async ({ page, browserName }) => {
+ test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol.");
+ await page.setContent(`
+
+ ${Array.from({ length: 12 }, (_, index) => `
Option ${index}
`).join("")}
+
`);
+ const picker = page.getByRole("dialog"), list = picker.getByRole("listbox"), last = list.getByRole("option").last();
+ const boundingBox = last.boundingBox.bind(last);
+ // Reproduce the observed compositor interleaving: a returned target rectangle
+ // is stale by the next scrollTop read because inertia reached the list end.
+ last.boundingBox = async (options) => {
+ const beforeMomentum = await boundingBox(options);
+ await list.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; });
+ return beforeMomentum;
+ };
+ await swipeToLastOption(page, picker, list, last);
+ await expect(picker).toBeVisible();
+});
+
test("new-task assignee, model, and project sheets scroll by touch and retain the selected values", async ({ page, request, browserName }, testInfo) => {
test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol.");
const company = await json(await request.post("/api/companies", {
diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts
index 471589cf3cc..da005094f2d 100644
--- a/tests/e2e/signoff-policy.spec.ts
+++ b/tests/e2e/signoff-policy.spec.ts
@@ -270,6 +270,29 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string):
};
}
+async function waitForFixtureRun(
+ board: APIRequestContext,
+ agentId: string,
+ issueId: string,
+ runId: string,
+) {
+ // Negative authorization cases deliberately invoke a non-participant. Leave
+ // their API rejection intact instead of waiting for a run they cannot own.
+ if ((await getIssueRunLockState(board, issueId)).assigneeAgentId !== agentId) return;
+ const waitBudgetMs = wakeupWaitBudgetMs();
+ const deadline = Date.now() + waitBudgetMs;
+ let status: string | null = null;
+ do {
+ const run = await fetchVerifiedRun(board, runId, agentId, issueId);
+ expect(run, "Fixture run must belong to the acting agent and issue").not.toBeNull();
+ status = run!.status;
+ if (status === "succeeded") return;
+ expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status);
+ await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS));
+ } while (Date.now() < deadline);
+ throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`);
+}
+
async function retryAgentPatchWithCurrentLockOnConflict(
board: APIRequestContext,
agent: AgentAuth,
@@ -286,6 +309,9 @@ async function retryAgentPatchWithCurrentLockOnConflict(
if (issueRunLock.assigneeAgentId !== agent.agentId) return res;
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId;
+ // A new lock can belong to a newly promoted fixture run, not the completed
+ // run we first invoked. Let that exact verified run settle before PATCH.
+ await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId);
res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": lockedRunId },
data: patchData,
@@ -325,6 +351,9 @@ async function agentPatch(
}: { maxAttempts?: number; backoffMs?: number; maxBackoffMs?: number } = {},
) {
const runId = await invokeHeartbeat(board, agent.agentId, issueId);
+ // The fixture process only prints "done". Let its startup and cleanup settle
+ // before a simulated decision reassigns the issue and cancels that process.
+ await waitForFixtureRun(board, agent.agentId, issueId, runId);
const patchWith = (patchRunId: string) =>
agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": patchRunId },
@@ -339,6 +368,7 @@ async function agentPatch(
// run-lock race — leave it for the caller to assert on.
if (issueRunLock.assigneeAgentId !== agent.agentId) break;
const retryRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? runId;
+ await waitForFixtureRun(board, agent.agentId, issueId, retryRunId);
res = await patchWith(retryRunId);
}
return res;
@@ -353,6 +383,7 @@ async function agentCheckoutAndPatch(
patchData: Record,
) {
const runId = await invokeHeartbeat(board, agent.agentId, issueId);
+ await waitForFixtureRun(board, agent.agentId, issueId, runId);
const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data: patchData,
From 762d680daf3563d42a0c4c280761c233b21713c7 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 08:11:31 -0500
Subject: [PATCH 02/23] feat(ui): upload files directly in task artifacts
(#15964)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip helps people manage agent work and retain its context.
> - Task documents and attachments keep that context with the task.
> - The Artifacts panel showed output but could not accept a new file.
> - Uploading through a message can require resuming a paused task.
> - Existing document and attachment APIs can save files directly.
> - This PR adds direct uploads and a drop target to the task panel.
> - People can add context while keeping work paused and unassigned.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
The task Artifacts panel and its existing document and attachment APIs.
**Subsystem affected**
ui/ — React board UI. No server, schema, or API changes.
**Current behavior**
The Artifacts panel cannot upload a file. It also excludes files
uploaded by people. The message composer can require a task to resume
before it submits attachments.
**Proposed behavior**
Use the upload icon or drop files anywhere in the open Artifacts panel.
The target includes the header, padding, and existing cards. Files save
directly to the task without a message or a pause change.
**Reason and benefit**
People can prepare a handoff on a paused, unassigned task without
starting agent work. Markdown becomes a task document. Other supported
files use attachment storage.
**Breaking changes**
Files from people now appear in Artifacts alongside agent output.
Promoted attachments still appear once. A repeated Markdown filename
creates a separate document instead of replacing an existing revision.
Existing APIs and limits stay the same.
**Additional context**
Related upload API work: Refs #7472 and Refs #7248. This PR adds the
missing task-panel entry point. ROADMAP.md lists Artifacts and Work
Products as available; this is a scoped UI improvement.
## What Changed
- Add an upload icon with no visible label or background.
- Accept file drops across the full task panel, including the desktop
portal header and mobile tabs.
- Show per-file progress and inline Retry and Dismiss actions.
- Import Markdown under a new document key and retain existing
revisions.
- Include files from people in Artifacts and dedupe promoted
attachments.
- Add component and task-journey stories for empty, populated, loading,
error, mobile, and drag-over states.
- Add real browser tests for persistence, pause preservation, duplicate
document names, full-panel drops, and partial failure recovery.
## Verification
- Five Playwright tests passed against an isolated instance started by
the source `test-drive` command. The document API, attachment storage,
pause holds, downloads, and task UI were real. Only the explicit upload
failure was injected.
- The browser tests verified desktop and mobile uploads, document
reading, persistence after reload, no task comments or runs, header
drops, preservation of original and hidden internal documents, reserved
filename handling, and upload retry.
- 163 focused UI tests passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm check:token-gates` passed.
- The full Vitest suite is running after repair of an exhausted local
PostgreSQL shared-memory pool. The initial run failed to initialize
PostgreSQL. Six unattached segments had no living creator or last-user
process; removing them restored free slots.
- CI and the current-head Greptile review are pending.
## Risks
- Artifacts now includes user files, so existing tasks can show more
cards.
- Uploads retain the server's authorization, file-type, size, and
revision checks. No migration is needed.
- Binary attachment uploads use the existing API. A transport failure
with an unknown save outcome can retain the existing API's retry
ambiguity.
## Model Used
OpenAI GPT-6 through Codex. The exact runtime snapshot ID and
context-window size were not exposed to this session. The model used
repository inspection, code editing, shell tools, and browser
automation.
## 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
- [ ] 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
---
doc/AGENT-ARTIFACTS.md | 14 ++
tests/e2e/artifact-uploads.spec.ts | 149 ++++++++++++++++++
.../components/artifacts/ArtifactDropzone.tsx | 75 +++++++++
.../artifacts/ArtifactUploadArea.tsx | 22 +++
.../artifacts/ArtifactUploadControls.tsx | 66 ++++++++
.../issue-properties/IssueProperties.tsx | 7 +-
.../IssuePropertiesArtifactsTab.tsx | 30 ++--
...ssuePropertiesMarkdownWorkProduct.test.tsx | 8 +-
.../components/side-panel/SidePanelFrame.tsx | 2 +-
.../task-side-panel/TaskSidePanel.tsx | 15 +-
ui/src/hooks/useIssueArtifactUploads.ts | 65 ++++++++
ui/src/lib/issue-artifact-upload.test.ts | 23 +++
ui/src/lib/issue-artifact-upload.ts | 15 ++
ui/src/lib/issue-artifacts.test.ts | 11 ++
ui/src/lib/issue-artifacts.ts | 9 ++
.../artifact-upload/01-components.stories.tsx | 22 +++
.../02-task-journey.stories.tsx | 59 +++++++
.../stories/artifact-upload/UploadPreview.tsx | 105 ++++++++++++
.../stories/artifact-upload/fixtures.ts | 129 +++++++++++++++
19 files changed, 802 insertions(+), 24 deletions(-)
create mode 100644 tests/e2e/artifact-uploads.spec.ts
create mode 100644 ui/src/components/artifacts/ArtifactDropzone.tsx
create mode 100644 ui/src/components/artifacts/ArtifactUploadArea.tsx
create mode 100644 ui/src/components/artifacts/ArtifactUploadControls.tsx
create mode 100644 ui/src/hooks/useIssueArtifactUploads.ts
create mode 100644 ui/src/lib/issue-artifact-upload.test.ts
create mode 100644 ui/src/lib/issue-artifact-upload.ts
create mode 100644 ui/storybook/stories/artifact-upload/01-components.stories.tsx
create mode 100644 ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx
create mode 100644 ui/storybook/stories/artifact-upload/UploadPreview.tsx
create mode 100644 ui/storybook/stories/artifact-upload/fixtures.ts
diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md
index 1d97ee4ae8c..e17d623d6a5 100644
--- a/doc/AGENT-ARTIFACTS.md
+++ b/doc/AGENT-ARTIFACTS.md
@@ -5,6 +5,20 @@ must be attached to the Paperclip issue before the agent chooses a final
disposition. A local workspace path is not enough, because cloud users and
reviewers often cannot access the agent's disk.
+## Upload files from a task
+
+People can open the task's Artifacts tab and use the upload icon, or drop files
+anywhere in the panel, including its header and existing artifact cards. Uploads
+save directly to the task. They do not send a message, assign an agent, or
+release a pause hold. Files from people and agents appear together in Artifacts.
+
+Markdown imports create task documents. Repeated filenames get a new document
+key instead of replacing an existing revision.
+Hidden system keys and artifact-review prefixes are reserved; imports with
+those filenames receive a visible upload document key. Other supported files use
+the existing attachment API and its type and size limits. Failed uploads show
+an inline Retry action while successful files remain available.
+
## Native runner
When `register_deliverable` is available, use it for files in the bound local or
diff --git a/tests/e2e/artifact-uploads.spec.ts b/tests/e2e/artifact-uploads.spec.ts
new file mode 100644
index 00000000000..8dc63aa6fb5
--- /dev/null
+++ b/tests/e2e/artifact-uploads.spec.ts
@@ -0,0 +1,149 @@
+import { randomUUID } from "node:crypto";
+import { expect, test, type APIRequestContext, type APIResponse, type Locator, type Page } from "@playwright/test";
+import { expectTaskPanelTab } from "./helpers/task-panel-tabs";
+
+// Real task UI, document revisions, attachment storage and pause holds.
+// Only the explicit failed-upload response in the recovery test is injected.
+async function json(response: APIResponse) {
+ expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
+ return response.json();
+}
+
+async function setup(page: Page, request: APIRequestContext, mobile = false) {
+ await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
+ await page.addInitScript(() => localStorage.setItem("paperclip:panel-visible", "true"));
+ const companies = await json(await request.get("/api/companies"));
+ const company = companies.find((row: { name: string }) => row.name === "Artifact Upload Review") ?? await json(await request.post("/api/companies", { data: { name: `Artifact uploads ${randomUUID()}` } }));
+ const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
+ data: { title: `Upload handoff files ${randomUUID().slice(0, 8)}`, status: "backlog", assigneeAgentId: null, assigneeUserId: null },
+ }));
+ await json(await request.post(`/api/issues/${issue.id}/tree-holds`, { data: { mode: "pause" } }));
+ await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}${mobile ? "?from=inbox&fromHref=%2Finbox%2Fmine" : ""}`);
+ await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
+ if (mobile) {
+ await page.getByRole("button", { name: "More actions", exact: true }).click();
+ await page.getByRole("button", { name: "Properties", exact: true }).click();
+ }
+ const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) });
+ await panel.getByRole("button", { name: "Open a new tab", exact: true }).click();
+ await page.getByRole("option", { name: "Artifacts", exact: true }).click();
+ await expectTaskPanelTab(panel, mobile, "Artifacts");
+ await expect(panel.getByRole("button", { name: "Upload files", exact: true })).toBeVisible();
+ return { company, issue, panel };
+}
+
+async function chooseFiles(page: Page, panel: Locator, files: { name: string; mimeType: string; buffer: Buffer }[]) {
+ const chooser = page.waitForEvent("filechooser");
+ await panel.getByRole("button", { name: "Upload files", exact: true }).click();
+ await (await chooser).setFiles(files);
+}
+
+async function assertStillPaused(request: APIRequestContext, issueId: string) {
+ const issue = await json(await request.get(`/api/issues/${issueId}`));
+ expect(issue.status).toBe("backlog");
+ expect(issue.assigneeAgentId).toBeNull();
+ expect(issue.assigneeUserId).toBeNull();
+ const state = await json(await request.get(`/api/issues/${issueId}/tree-control/state`));
+ expect(state.activePauseHold).not.toBeNull();
+ expect(await json(await request.get(`/api/issues/${issueId}/comments`))).toEqual([]);
+ expect(await json(await request.get(`/api/issues/${issueId}/runs`))).toEqual([]);
+}
+
+for (const mobile of [false, true]) {
+ test(`upload documents and files while a task remains paused (mobile=${mobile})`, async ({ page, request }, testInfo) => {
+ const { issue, panel } = await setup(page, request, mobile);
+ await chooseFiles(page, panel, [
+ { name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Engineering handoff\n\nStart with the loop inventory and preserve current task ownership.") },
+ { name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from("path,pattern\ndelivery,event-driven\nroutines,clock-driven") },
+ ]);
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
+ await expect(panel.getByRole("link", { name: "Download file", exact: true })).toBeVisible();
+ await expectTaskPanelTab(panel, mobile, "Artifacts");
+ const download = page.waitForEvent("download");
+ await panel.getByRole("link", { name: "Download file", exact: true }).click();
+ expect((await download).suggestedFilename()).toBe("loop-inventory.csv");
+ await panel.getByRole("button", { name: "Read document", exact: true }).click();
+ await expect(panel.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
+ await assertStillPaused(request, issue.id);
+ await page.reload();
+ await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ const attachments = await json(await request.get(`/api/issues/${issue.id}/attachments`));
+ expect(documents).toHaveLength(1);
+ expect(documents[0].key).toBe("core-handoff");
+ expect(attachments).toHaveLength(1);
+ expect(attachments[0].originalFilename).toBe("loop-inventory.csv");
+ await assertStillPaused(request, issue.id);
+ await page.screenshot({ path: testInfo.outputPath("uploaded-handoff-persisted.png"), fullPage: true });
+ });
+}
+
+test("the entire populated panel accepts a drop and preserves an existing document", async ({ page, request }, testInfo) => {
+ const { issue, panel } = await setup(page, request);
+ await chooseFiles(page, panel, [{ name: "handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Original handoff\n\nPreserve this version.") }]);
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
+ const transfer = await page.evaluateHandle(() => {
+ const data = new DataTransfer();
+ data.items.add(new File(["# Additional handoff\n\nNew reference, separate document."], "handoff.md", { type: "text/markdown" }));
+ data.items.add(new File(["Check saved deadlines before enabling instance sleep."], "verification.txt", { type: "text/plain" }));
+ return data;
+ });
+ const header = panel.locator("[data-presentation] > header");
+ await header.dispatchEvent("dragenter", { dataTransfer: transfer });
+ const overlay = panel.locator("[data-artifact-drop-overlay]");
+ await expect(overlay).toBeVisible();
+ const frameBounds = await panel.locator("[data-presentation]").boundingBox();
+ const overlayBounds = await overlay.boundingBox();
+ expect(overlayBounds).toEqual(frameBounds);
+ await page.screenshot({ path: testInfo.outputPath("entire-panel-dropzone.png"), fullPage: true });
+ await header.dispatchEvent("dragleave", { dataTransfer: transfer });
+ await expect(overlay).toBeHidden();
+ await header.dispatchEvent("dragenter", { dataTransfer: transfer });
+ await panel.locator("article").first().dispatchEvent("drop", { dataTransfer: transfer });
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(2);
+ await expect(panel.getByText("verification.txt", { exact: true }).first()).toBeVisible();
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ expect(documents.map((row: { key: string }) => row.key).sort()).toEqual(["handoff", "handoff-2"]);
+ expect(documents.find((row: { key: string }) => row.key === "handoff").body).toContain("Preserve this version.");
+ await assertStillPaused(request, issue.id);
+});
+
+test("a failed upload stays local and retry preserves the successful files", async ({ page, request }) => {
+ const { issue, panel } = await setup(page, request);
+ let fail = true;
+ await page.route(`**/api/companies/*/issues/${issue.id}/attachments`, async route => {
+ if (fail) { fail = false; await route.fulfill({ status: 422, contentType: "application/json", body: JSON.stringify({ error: "Upload failed. Try again." }) }); }
+ else await route.continue();
+ });
+ await chooseFiles(page, panel, [
+ { name: "failed-reference.txt", mimeType: "text/plain", buffer: Buffer.from("Reference restored after retry.") },
+ { name: "saved-reference.txt", mimeType: "text/plain", buffer: Buffer.from("Keep this successful file.") },
+ ]);
+ await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
+ await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/attachments`))).length).toBe(1);
+ await panel.getByRole("button", { name: "Retry", exact: true }).click();
+ await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeHidden();
+ await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/attachments`))).map((row: { originalFilename: string }) => row.originalFilename).sort()).toEqual(["failed-reference.txt", "saved-reference.txt"]);
+ await assertStillPaused(request, issue.id);
+});
+
+test("reserved filenames create visible documents without replacing internal revisions", async ({ page, request }) => {
+ const { issue, panel } = await setup(page, request);
+ const reserved = ["artifact-review-notes", "continuation-summary", "pipeline-case-body"];
+ const originals = new Map();
+ for (const key of reserved) {
+ const document = await json(await request.put(`/api/issues/${issue.id}/documents/${key}`, {
+ data: { title: "Internal context", format: "markdown", body: `Preserve the internal ${key} document.`, baseRevisionId: null },
+ }));
+ originals.set(key, document.latestRevisionId);
+ }
+ await chooseFiles(page, panel, reserved.map(key => ({ name: `${key}.md`, mimeType: "text/markdown", buffer: Buffer.from(`# Uploaded ${key}\n\nThis reference must stay visible.`) })));
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(3);
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ expect(documents.filter((row: { key: string }) => row.key.startsWith("upload-")).map((row: { key: string }) => row.key).sort()).toEqual(reserved.map(key => `upload-${key}`).sort());
+ for (const key of reserved) {
+ const document = await json(await request.get(`/api/issues/${issue.id}/documents/${key}`));
+ expect(document.latestRevisionId).toBe(originals.get(key));
+ }
+ await assertStillPaused(request, issue.id);
+});
diff --git a/ui/src/components/artifacts/ArtifactDropzone.tsx b/ui/src/components/artifacts/ArtifactDropzone.tsx
new file mode 100644
index 00000000000..7830ac1bc0a
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactDropzone.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { Upload } from "lucide-react";
+
+/** The target can be the host frame when its tab header is rendered by a portal. */
+export function ArtifactDropzone({ children, onUpload, enabled = true, target }: {
+ children: ReactNode;
+ onUpload: (files: File[]) => void;
+ enabled?: boolean;
+ target?: HTMLElement | null;
+}) {
+ const rootRef = useRef(null);
+ const uploadRef = useRef(onUpload);
+ uploadRef.current = onUpload;
+ const [dragging, setDragging] = useState(false);
+
+ useEffect(() => {
+ const element = target ?? rootRef.current;
+ setDragging(false);
+ if (!enabled || !element) return;
+ let depth = 0;
+ const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
+ const enter = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ depth += 1;
+ setDragging(true);
+ };
+ const over = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ };
+ const leave = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.stopPropagation();
+ depth = Math.max(0, depth - 1);
+ if (depth === 0) setDragging(false);
+ };
+ const drop = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ depth = 0;
+ setDragging(false);
+ const files = Array.from(event.dataTransfer?.files ?? []);
+ if (files.length) uploadRef.current(files);
+ };
+ element.addEventListener("dragenter", enter);
+ element.addEventListener("dragover", over);
+ element.addEventListener("dragleave", leave);
+ element.addEventListener("drop", drop);
+ return () => {
+ element.removeEventListener("dragenter", enter);
+ element.removeEventListener("dragover", over);
+ element.removeEventListener("dragleave", leave);
+ element.removeEventListener("drop", drop);
+ };
+ }, [enabled, target]);
+
+ const overlay = dragging ? (
+
+
+
Drop files to add them to this task
+
+ ) : null;
+ return (
+
+ {children}
+ {target && overlay ? createPortal(overlay, target) : overlay}
+
+ );
+}
diff --git a/ui/src/components/artifacts/ArtifactUploadArea.tsx b/ui/src/components/artifacts/ArtifactUploadArea.tsx
new file mode 100644
index 00000000000..5b36c005c25
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactUploadArea.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+import { SidePanelFrame } from "@/components/side-panel/SidePanelFrame";
+import { ArtifactDropzone } from "./ArtifactDropzone";
+import { ArtifactUploadButton, ArtifactUploadContent, type ArtifactUploadEntry } from "./ArtifactUploadControls";
+export type { ArtifactUploadEntry } from "./ArtifactUploadControls";
+
+/** Standalone composition of the same controls used by task panel hosts. */
+export function ArtifactUploadArea({ children, header, empty = false, entries = [], onUpload, onRetry, onDismiss }: {
+ children?: ReactNode;
+ header?: ReactNode;
+ empty?: boolean;
+ entries?: ArtifactUploadEntry[];
+ onUpload: (files: File[]) => void;
+ onRetry?: (id: string) => void;
+ onDismiss?: (id: string) => void;
+}) {
+ return
+ }>
+ {children}
+
+ ;
+}
diff --git a/ui/src/components/artifacts/ArtifactUploadControls.tsx b/ui/src/components/artifacts/ArtifactUploadControls.tsx
new file mode 100644
index 00000000000..ae86a645890
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactUploadControls.tsx
@@ -0,0 +1,66 @@
+import { useRef, type ReactNode } from "react";
+import { FileUp, Loader2, Upload, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+export interface ArtifactUploadEntry {
+ id: string;
+ filename: string;
+ status: "uploading" | "error";
+ error?: string;
+}
+
+export function ArtifactUploadButton({ onUpload }: { onUpload: (files: File[]) => void }) {
+ const inputRef = useRef(null);
+ return <>
+ {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+ if (files.length) onUpload(files);
+ }} />
+
+
+ inputRef.current?.click()}>
+
+
+
+ Upload files
+
+ >;
+}
+
+export function ArtifactUploadContent({ children, empty = false, entries = [], onRetry, onDismiss }: {
+ children?: ReactNode;
+ empty?: boolean;
+ entries?: ArtifactUploadEntry[];
+ onRetry?: (id: string) => void;
+ onDismiss?: (id: string) => void;
+}) {
+ return
+ {entries.length > 0 ?
+ {entries.map((entry) =>
+ {entry.status === "uploading"
+ ?
+ : }
+
+ {entry.filename}
+
+ {entry.status === "uploading" ? "Uploading…" : entry.error ?? "Upload failed. Try again."}
+
+
+ {entry.status === "error" ?
+ {onRetry ? onRetry(entry.id)}>Retry : null}
+ {onDismiss ? onDismiss(entry.id)}> : null}
+
: null}
+ )}
+ : null}
+ {empty ?
+
+
+
Add files to this task
+
Drop files anywhere in this panel or use the upload icon.
+
+
: children}
+
;
+}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 2b00c76abae..2eb00aa6503 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -29,7 +29,7 @@ import { issuesApi } from "../../api/issues";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
-import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts";
+import { selectArtifactAttachments } from "@/lib/issue-artifacts";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
@@ -321,12 +321,11 @@ export function IssueProperties({
|| paneTabStandaloneDocuments.length > 0;
// Artifacts covers the same three sources the tab body composes: work
// products, documents (redundant with the Plan tab, intentionally), and
- // agent-created attachments. User comment uploads stay thread-only and
- // no longer summon the tab.
+ // attachments from agents and people.
const hasArtifactsTab =
(paneTabWorkProducts?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
- || selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
+ || selectArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
const [paneTab, setPaneTab] = useState("properties");
const [closedPaneTabs, setClosedPaneTabs] = useState>(() => new Set());
// Once a plan document exists, surface it: switch the pane to the Plan tab so
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
index 17d1bd1cffc..bc573fba519 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
@@ -1,5 +1,6 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
-import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { ArtifactUploadContent, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
AttachmentArtifactWorkProductMetadata,
@@ -23,7 +24,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useIssueWorkProducts } from "@/hooks/useIssueWorkProducts";
import {
documentDisplayTitle,
- selectAgentArtifactAttachments,
+ selectArtifactAttachments,
} from "@/lib/issue-artifacts";
import { DocumentCard } from "@/components/artifacts/RichArtifactCards";
import {
@@ -44,6 +45,7 @@ import { useLocation } from "@/lib/router";
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
+ uploadState?: { entries: ArtifactUploadEntry[]; retry: (id: string) => void; dismiss: (id: string) => void };
onOpenDocument?: (document: IssueDocument) => void;
documentDeepLink?: {
requestId: number;
@@ -355,17 +357,16 @@ function DocumentRow({
/**
* Artifacts tab of the properties pane (PAP-491).
*
- * A read-only "what did this task produce" view composed from three sources:
+ * Task files and output composed from three sources:
* work products, issue documents (also readable in the Plan tab — the
- * redundancy is intentional), and agent-created attachments. Attachments
- * already promoted to attachment-backed work products are deduped out, and
- * user uploads are excluded — those stay first-class in the conversation
- * thread.
+ * redundancy is intentional), and attachments from agents and people.
+ * Attachments already promoted to work products are deduped out.
*/
export function IssuePropertiesArtifactsTab({
issue,
documentDeepLink,
onOpenDocument,
+ uploadState,
}: IssuePropertiesArtifactsTabProps) {
const {
data: attachments,
@@ -406,7 +407,7 @@ export function IssuePropertiesArtifactsTab({
const reviewDocsByKey = new Map(
(documents ?? []).map((doc) => [doc.key, doc]),
);
- const fileRows = selectAgentArtifactAttachments(attachments, workProducts);
+ const fileRows = selectArtifactAttachments(attachments, workProducts);
const runsById = useMemo(
() => new Map((runs ?? []).map((run) => [run.runId, run])),
[runs],
@@ -513,21 +514,26 @@ export function IssuePropertiesArtifactsTab({
) : null;
+ const withUploads = (content: ReactNode, empty = false) => uploadState ? (
+ {content}
+ ) : content;
+
if (
workProductRows.length === 0 &&
documentRows.length === 0 &&
fileRows.length === 0
) {
- if (feedback) return feedback;
- return (
+ if (feedback) return withUploads(feedback);
+ return withUploads(
No artifacts yet. Work products, documents, and agent-produced files
will appear here.
-
+ ,
+ true,
);
}
- return (
+ return withUploads(
{feedback}
{groupedRows.map((group) => {
diff --git a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
index 0b52378d5e4..d2fc25f5ffc 100644
--- a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
@@ -415,7 +415,7 @@ describe("markdown work product review row", () => {
expect(container.querySelector("button[aria-expanded]")).toBeNull();
});
- it.each(["image/png", "application/octet-stream"])("renders %s media tiles without duplicates or user uploads", async (contentType) => {
+ it.each(["image/png", "application/octet-stream"])("renders %s media tiles with user uploads and without promoted duplicates", async (contentType) => {
const image = { ...makeMarkdownAttachment(), contentType, originalFilename: "cover.png" };
const looseImage = { ...image, id: "loose-image", contentPath: "/api/attachments/loose-image/content" };
const video = { ...makeMarkdownAttachment(), id: "loose-video", contentType: "application/octet-stream", originalFilename: "clip.mp4", contentPath: "/api/attachments/loose-video/content" };
@@ -427,11 +427,11 @@ describe("markdown work product review row", () => {
await renderTab({}, "Cover artwork");
await waitForAssertion(() => {
const buttons = container.querySelectorAll('button[aria-label^="View image:"], button[aria-label^="Open video:"]');
- expect(buttons).toHaveLength(3);
+ expect(buttons).toHaveLength(4);
expect(container.querySelectorAll("img")).toHaveLength(2);
- expect(container.querySelectorAll("article")).toHaveLength(3);
+ expect(container.querySelectorAll("article")).toHaveLength(4);
expect(container.querySelector("video")?.getAttribute("src")).toBe(video.contentPath);
- expect(container.querySelectorAll("video")).toHaveLength(1);
+ expect(container.querySelectorAll("video")).toHaveLength(2);
});
});
diff --git a/ui/src/components/side-panel/SidePanelFrame.tsx b/ui/src/components/side-panel/SidePanelFrame.tsx
index 614455e81b4..320c9cde4ff 100644
--- a/ui/src/components/side-panel/SidePanelFrame.tsx
+++ b/ui/src/components/side-panel/SidePanelFrame.tsx
@@ -49,7 +49,7 @@ export function SidePanelFrame({
data-maximized={maximized ? "true" : "false"}
data-resizing={resizing ? "true" : "false"}
className={cn(
- "flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--side-panel-bg) text-(--side-panel-fg)",
+ "relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--side-panel-bg) text-(--side-panel-fg)",
presentation === "docked" && "h-full border-l border-border",
presentation === "sheet" && "h-full w-full",
presentation === "embedded" && "h-full rounded-xl border border-border",
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx
index 3e1e5e754f0..ee28cab900d 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx
@@ -37,6 +37,9 @@ import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
+import { ArtifactDropzone } from "@/components/artifacts/ArtifactDropzone";
+import { ArtifactUploadButton } from "@/components/artifacts/ArtifactUploadControls";
+import { useIssueArtifactUploads } from "@/hooks/useIssueArtifactUploads";
import { IssuePropertiesPlansTab } from "@/components/issue-properties/IssuePropertiesPlansTab";
import { TaskDetailSubtasksPanel } from "@/components/task-detail/TaskDetailRelationsPanel";
import {
@@ -274,6 +277,7 @@ export function TaskSidePanel({
const conversationAgentId = agentChatEnabled ? issue.conversationAgentId ?? null : null;
const showRelatedTasks = showSubtasksTab && !conversationAgentId;
const viewer = useTaskSidePanelFileRouting();
+ const artifactUploads = useIssueArtifactUploads(issue);
const { data: documentsData } = useIssueDocuments(issue.id);
const documents = documentsData ?? EMPTY_ISSUE_DOCUMENTS;
const { data: planDocument } = useIssuePlanDocument(issue.id);
@@ -353,6 +357,7 @@ export function TaskSidePanel({
}
}, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]);
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
+ const canUploadArtifacts = activeTab?.payload.kind === "artifacts" && !conversationAgentId && !issue.id.startsWith("chat:");
const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
@@ -760,7 +765,7 @@ export function TaskSidePanel({
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
- }}>
;
+ }}>
;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" ? (
@@ -815,11 +820,14 @@ export function TaskSidePanel({
}
const contentMode = activeTab?.contentMode ?? "full-bleed";
+ const header = <>{tabStrip}{canUploadArtifacts ?
: null}>;
return (
+
("[data-presentation]")} onUpload={artifactUploads.upload}>
- {paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : (
+
{artifactUploads.announcement}
+ {paneHeaderSlot ? createPortal(header, paneHeaderSlot) : (
- {tabStrip}
+ {header}
{onRequestClose ? (
mobile ? (
@@ -855,5 +863,6 @@ export function TaskSidePanel({
) : content}
+
);
}
diff --git a/ui/src/hooks/useIssueArtifactUploads.ts b/ui/src/hooks/useIssueArtifactUploads.ts
new file mode 100644
index 00000000000..bc058b77fc4
--- /dev/null
+++ b/ui/src/hooks/useIssueArtifactUploads.ts
@@ -0,0 +1,65 @@
+import { useCallback, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { Issue, IssueAttachment, IssueDocument } from "@paperclipai/shared";
+import { issuesApi } from "@/api/issues";
+import { queryKeys } from "@/lib/queryKeys";
+import { uploadedDocumentKey } from "@/lib/issue-artifact-upload";
+import type { ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
+
+export function useIssueArtifactUploads(issue: Pick
) {
+ const client = useQueryClient();
+ const [entries, setEntries] = useState([]);
+ const [announcement, setAnnouncement] = useState("");
+ const files = useRef(new Map());
+ const queue = useRef>(Promise.resolve());
+
+ const enqueue = useCallback((batch: { id: string; file: File }[]) => {
+ for (const { id, file } of batch) files.current.set(id, file);
+ setEntries(current => [
+ ...current.filter(entry => !batch.some(file => file.id === entry.id)),
+ ...batch.map(({ id, file }): ArtifactUploadEntry => ({ id, filename: file.name, status: "uploading" })),
+ ]);
+ // Serialize imports so repeated filenames in successive selections get
+ // distinct keys. The server still rejects concurrent document creates.
+ queue.current = queue.current.then(async () => {
+ for (const { id, file } of batch) {
+ try {
+ if (/\.(md|markdown)$/i.test(file.name) || file.type === "text/markdown") {
+ const body = await file.text();
+ const documents = await issuesApi.listDocuments(issue.id);
+ const basename = file.name.replace(/\.[^.]+$/, "");
+ const key = uploadedDocumentKey(file.name, documents.map(document => document.key));
+ const document = await issuesApi.upsertDocument(issue.id, key, {
+ title: basename.replace(/[-_]+/g, " ").slice(0, 200), format: "markdown", body, baseRevisionId: null,
+ });
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], current => [document, ...(current ?? []).filter(row => row.id !== document.id)]);
+ void client.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
+ } else {
+ const attachment = await issuesApi.uploadAttachment(issue.companyId, issue.id, file);
+ client.setQueryData(queryKeys.issues.attachments(issue.id), current => [attachment, ...(current ?? []).filter(row => row.id !== attachment.id)]);
+ void client.invalidateQueries({ queryKey: queryKeys.issues.attachments(issue.id) });
+ }
+ void client.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
+ files.current.delete(id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ setAnnouncement(`${file.name} added to this task.`);
+ } catch (error) {
+ setEntries(current => current.map(entry => entry.id === id ? {
+ ...entry, status: "error", error: error instanceof Error ? error.message : "Upload failed. Try again.",
+ } : entry));
+ }
+ }
+ });
+ }, [client, issue.companyId, issue.id]);
+
+ const upload = useCallback((picked: File[]) => enqueue(picked.map(file => ({ id: crypto.randomUUID(), file }))), [enqueue]);
+ const retry = useCallback((id: string) => {
+ const file = files.current.get(id);
+ if (file) enqueue([{ id, file }]);
+ }, [enqueue]);
+ const dismiss = useCallback((id: string) => {
+ files.current.delete(id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ }, []);
+ return { entries, announcement, upload, retry, dismiss };
+}
diff --git a/ui/src/lib/issue-artifact-upload.test.ts b/ui/src/lib/issue-artifact-upload.test.ts
new file mode 100644
index 00000000000..d4cfb1d58c8
--- /dev/null
+++ b/ui/src/lib/issue-artifact-upload.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
+import { uploadedDocumentKey } from "./issue-artifact-upload";
+
+describe("uploadedDocumentKey", () => {
+ it.each(["artifact-review-notes", "continuation-summary", "pipeline-case-body"])("keeps %s uploads visible and separate from internal documents", (reserved) => {
+ const key = uploadedDocumentKey(`${reserved}.md`, [reserved]);
+ expect(key).toBe(`upload-${reserved}`);
+ expect(isArtifactReviewDocumentKey(key)).toBe(false);
+ expect(isSystemIssueDocumentKey(key)).toBe(false);
+ expect(uploadedDocumentKey(`${reserved}.md`, [key])).toBe(`${key}-2`);
+ });
+
+ it("preserves existing filename-derived documents and bounds long keys", () => {
+ expect(uploadedDocumentKey("Core handoff.md", ["core-handoff", "core-handoff-2"])).toBe("core-handoff-3");
+ const filename = `artifact-review-${"notes-".repeat(30)}.md`;
+ const first = uploadedDocumentKey(filename, []);
+ const repeated = uploadedDocumentKey(filename, [first]);
+ expect(issueDocumentKeySchema.safeParse(repeated).success).toBe(true);
+ expect(isArtifactReviewDocumentKey(repeated)).toBe(false);
+ expect(repeated).not.toBe(first);
+ });
+});
diff --git a/ui/src/lib/issue-artifact-upload.ts b/ui/src/lib/issue-artifact-upload.ts
new file mode 100644
index 00000000000..61355412d50
--- /dev/null
+++ b/ui/src/lib/issue-artifact-upload.ts
@@ -0,0 +1,15 @@
+import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
+
+/** Uploaded filenames must never select a hidden or internal document key. */
+export function uploadedDocumentKey(filename: string, existingKeys: Iterable): string {
+ const slug = filename.replace(/\.[^.]+$/, "").toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "document";
+ const visibleSlug = isSystemIssueDocumentKey(slug) || isArtifactReviewDocumentKey(slug)
+ ? `upload-${slug}` : slug;
+ const base = visibleSlug.slice(0, 54);
+ const keys = new Set(existingKeys);
+ let key = base;
+ let suffix = 2;
+ while (keys.has(key)) key = `${base}-${suffix++}`;
+ return key;
+}
diff --git a/ui/src/lib/issue-artifacts.test.ts b/ui/src/lib/issue-artifacts.test.ts
index 42dc6d3ee8b..1326c06a778 100644
--- a/ui/src/lib/issue-artifacts.test.ts
+++ b/ui/src/lib/issue-artifacts.test.ts
@@ -5,6 +5,7 @@ import {
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
selectAgentArtifactAttachments,
+ selectArtifactAttachments,
workProductHref,
} from "./issue-artifacts";
@@ -134,6 +135,16 @@ describe("selectAgentArtifactAttachments", () => {
});
});
+describe("selectArtifactAttachments", () => {
+ it("includes files from people while deduping promoted files", () => {
+ const promotedId = "00000000-0000-4000-8000-000000000001";
+ const user = makeAttachment({ id: "user-file", createdByUserId: "user-1", issueCommentId: "comment-1" });
+ const agent = makeAttachment({ id: "agent-file", createdByAgentId: "agent-1" });
+ const promoted = makeAttachment({ id: promotedId, createdByUserId: "user-1" });
+ expect(selectArtifactAttachments([user, agent, promoted], [makePromotingWorkProduct(promotedId)])).toEqual([user, agent]);
+ });
+});
+
describe("getAttachmentBackedWorkProductAttachmentIds", () => {
it("collects attachment ids across content types", () => {
const imageId = "00000000-0000-4000-8000-000000000001";
diff --git a/ui/src/lib/issue-artifacts.ts b/ui/src/lib/issue-artifacts.ts
index f3f3d8a6d1f..e7af1582bbb 100644
--- a/ui/src/lib/issue-artifacts.ts
+++ b/ui/src/lib/issue-artifacts.ts
@@ -51,6 +51,15 @@ export function selectAgentArtifactAttachments(
);
}
+/** All task files, regardless of author, with promoted work products deduped. */
+export function selectArtifactAttachments(
+ attachments: IssueAttachment[] | null | undefined,
+ workProducts: IssueWorkProduct[] | null | undefined,
+): IssueAttachment[] {
+ const promoted = getAttachmentBackedWorkProductAttachmentIds(workProducts);
+ return (attachments ?? []).filter(attachment => !promoted.has(attachment.id));
+}
+
/**
* Where a work-product row should link. Many rows carry `url: null` with the
* usable link only in metadata — `openPath` for attachment-backed artifacts
diff --git a/ui/storybook/stories/artifact-upload/01-components.stories.tsx b/ui/storybook/stories/artifact-upload/01-components.stories.tsx
new file mode 100644
index 00000000000..2e33e82d78b
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/01-components.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { UploadPreview } from "./UploadPreview";
+import { showFileDrag } from "./fixtures";
+
+const meta = {
+ title: "Artifact upload/01 Artifacts pane", component: UploadPreview,
+ parameters: { layout: "fullscreen", docs: { description: { component: "The upload icon sits in the tab header without text or a background. The entire Artifacts panel, including its header, padding, existing cards and empty space, accepts files. Drag-over stories enter through the actual header drag handler and highlight the complete visible panel, including after scrolling. Use the upload icon for one or several files. Markdown becomes a document; other files use artifact cards. Uploads are simulated in memory and reset when the story reloads. The Uploading story holds its example pending; Retry completes the sample CSV upload. No task or execution state changes." }, story: { inline: false } } },
+ argTypes: { state: { control: "select", options: ["empty", "populated", "uploading", "error", "long"] } },
+ render: (args, context) =>
,
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Empty: Story = { args: { state: "empty" } };
+export const Populated: Story = { args: { state: "populated" } };
+export const Uploading: Story = { args: { state: "uploading" } };
+export const RetryUpload: Story = { args: { state: "error" } };
+export const LongReference: Story = { args: { state: "long" } };
+export const DragOverEmptyPanel: Story = { args: { state: "empty" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const DragOverExistingArtifacts: Story = { args: { state: "populated" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const DragOverScrolledArtifacts: Story = { args: { state: "long" }, play: ({ canvasElement }) => showFileDrag(canvasElement, true) };
+export const Mobile: Story = { args: { state: "populated" }, globals: { viewport: { value: "mobile", isRotated: false } } };
+export const MobileError: Story = { args: { state: "error" }, globals: { viewport: { value: "mobile", isRotated: false } } };
diff --git a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx b/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx
new file mode 100644
index 00000000000..6068e2d1848
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Route, Routes } from "@/lib/router";
+import { Layout } from "@/components/Layout";
+import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
+import { TaskChatPausedTakeover } from "@/components/task-chat/TaskChatPausedTakeover";
+import { StatusBadge } from "@/components/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Box, X } from "lucide-react";
+import { PluginLauncherProvider } from "@/plugins/launchers";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useSidebar } from "@/context/SidebarContext";
+import { UploadPreview } from "./UploadPreview";
+import { issue, showFileDrag, type PreviewState } from "./fixtures";
+
+function TaskPage({ state = "empty", panelOpenInitially = false }: { state?: PreviewState; panelOpenInitially?: boolean }) {
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const { isMobile: mobile } = useSidebar();
+ const [open, setOpen] = useState(panelOpenInitially);
+ useEffect(() => { setBreadcrumbs([{ label: "Tasks", href: "/PAP/issues" }, { label: issue.title, identifier: issue.identifier ?? undefined }]); }, [setBreadcrumbs]);
+ return
+
+
+
+
+
PAP-214 Unassigned
+ {mobile ?
setOpen(true)}> Artifacts : null}
+
+ {issue.title}
+
+
+
+
+
+
+
+ {mobile ? setOpen(false)}> Back to task setOpen(false)}> : null}
+
+
+
;
+}
+
+function Journey(props: { state?: PreviewState; panelOpenInitially?: boolean }) {
+ return }> } /> ;
+}
+
+const meta = {
+ title: "Artifact upload/02 Paused task journey", component: Journey,
+ parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-214"], docs: { description: { component: "Start on the paused, unassigned PAP-214 placeholder in the production app shell. Upload or drop files in Artifacts, open a document in another tab, then return to Artifacts. On mobile, open Artifacts from the task header. Uses production chat bubbles, paused takeover, panel frame, desktop/mobile tabs, artifact list and document viewer. The story composes these components for review; upload persistence is simulated. No live issue is modified or resumed." }, story: { inline: false } } },
+ render: (args, context) => ,
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const AddToPlaceholder: Story = { args: { state: "empty" } };
+export const HandoffAttached: Story = { args: { state: "populated" } };
+export const DragOverArtifacts: Story = { args: { state: "populated" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const RecoverAnUpload: Story = { args: { state: "error" } };
+export const MobileFromTask: Story = { args: { state: "empty" }, globals: { viewport: { value: "mobile", isRotated: false } } };
+export const MobileArtifacts: Story = { args: { state: "populated", panelOpenInitially: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
diff --git a/ui/storybook/stories/artifact-upload/UploadPreview.tsx b/ui/storybook/stories/artifact-upload/UploadPreview.tsx
new file mode 100644
index 00000000000..556346f750b
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/UploadPreview.tsx
@@ -0,0 +1,105 @@
+import { useLayoutEffect, useRef, useState, type MouseEvent } from "react";
+import { QueryClientProvider, useQuery } from "@tanstack/react-query";
+import type { IssueDocument } from "@paperclipai/shared";
+import { ArtifactUploadArea, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadArea";
+import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
+import { TaskDocumentPanel } from "@/components/task-side-panel/TaskDocumentPanel";
+import { SidePanelFrame } from "@/components/side-panel/SidePanelFrame";
+import { SidePanelTabs } from "@/components/side-panel/SidePanelTabs";
+import { SidePanelMobileTabs } from "@/components/side-panel/SidePanelMobileTabs";
+import type { SidePanelTabItem } from "@/components/side-panel/types";
+import { queryKeys } from "@/lib/queryKeys";
+import { useSidebar } from "@/context/SidebarContext";
+import { addPreviewFile, createPreviewClient, installPreviewApi, issue, type PreviewState } from "./fixtures";
+
+export function UploadPreview({ state = "empty" }: { state?: PreviewState }) {
+ const [client] = useState(() => createPreviewClient(state));
+ const [entries, setEntries] = useState(() => state === "error" ? [{ id: "failed-upload", filename: "remaining-work.csv", status: "error", error: "Connection lost. Your file hasn’t been added." }] : state === "uploading" ? [{ id: "pending-upload", filename: "remaining-work.csv", status: "uploading" }] : []);
+ const files = useRef(new Map());
+ const alive = useRef(true);
+ const [announcement, setAnnouncement] = useState("");
+ const [tabs, setTabs] = useState([{ id: "artifacts", type: "view", label: "Artifacts", closable: false }]);
+ const [activeTab, setActiveTab] = useState("artifacts");
+ const [selectedDocuments, setSelectedDocuments] = useState>({});
+ const { isMobile: mobile } = useSidebar();
+ const docQuery = useQuery({ queryKey: [...queryKeys.issues.documents(issue.id), "list"], queryFn: async () => [] as IssueDocument[], initialData: client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]), staleTime: Infinity }, client);
+ const productQuery = useQuery({ queryKey: queryKeys.issues.workProducts(issue.id), queryFn: async () => [], initialData: client.getQueryData(queryKeys.issues.workProducts(issue.id)), staleTime: Infinity }, client);
+
+ useLayoutEffect(() => {
+ alive.current = true;
+ files.current.set("failed-upload", new File(["path,pattern\ndelivery,event-driven\nroutines,clock-driven"], "remaining-work.csv", { type: "text/csv" }));
+ const restore = installPreviewApi(client);
+ return () => { alive.current = false; restore(); };
+ }, [client]);
+
+ async function upload(batch: { id: string; file: File }[]) {
+ for (const { id, file } of batch) {
+ files.current.set(id, file);
+ setEntries(current => [...current.filter(entry => entry.id !== id), { id, filename: file.name, status: "uploading" }]);
+ }
+ await Promise.all(batch.map(async ({ id, file }) => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ if (!alive.current) return;
+ try {
+ await addPreviewFile(client, file, id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ setAnnouncement(`${file.name} added to this task.`);
+ files.current.delete(id);
+ } catch {
+ setEntries(current => current.map(entry => entry.id === id ? { ...entry, status: "error", error: "This file couldn’t be read. Try again." } : entry));
+ }
+ }));
+ }
+
+ function openDocument(document: IssueDocument) {
+ setSelectedDocuments(current => ({ ...current, [document.key]: document }));
+ setTabs(current => current.some(tab => tab.id === document.key) ? current : [...current, { id: document.key, type: "document", label: document.title ?? document.key, closable: true }]);
+ setActiveTab(document.key);
+ }
+
+ function closeTab(id: string) {
+ if (id === "artifacts") return;
+ setTabs(current => current.filter(tab => tab.id !== id));
+ if (activeTab === id) setActiveTab("artifacts");
+ }
+
+ function handlePreviewLink(event: MouseEvent) {
+ const link = (event.target as HTMLElement).closest("a");
+ if (!link) return;
+ const url = new URL(link.href, window.location.origin);
+ const key = url.searchParams.get("doc");
+ if (key) {
+ const document = docQuery.data?.find(doc => doc.key === key);
+ if (document) { event.preventDefault(); openDocument(document); }
+ return;
+ }
+ const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)\/content$/);
+ const file = attachmentMatch && client.getQueryData(["storybook-upload-file", attachmentMatch[1]]);
+ if (!file) return;
+ event.preventDefault();
+ // Browser navigations bypass the in-memory fetch fixture. Supply the
+ // preview's bytes locally for the production card's open/download links.
+ const href = URL.createObjectURL(file);
+ if (url.searchParams.has("download")) {
+ const download = window.document.createElement("a");
+ download.href = href;
+ download.download = file.name;
+ download.click();
+ } else window.open(href, "_blank", "noopener,noreferrer");
+ window.setTimeout(() => URL.revokeObjectURL(href), 60_000);
+ }
+
+ const tabProps = { tabs, activeTabId: activeTab, onActiveTabChange: setActiveTab, onCloseTab: closeTab };
+ const header = mobile ? : ;
+ return
+
+
{announcement}
+ {activeTab === "artifacts" ?
void upload(picked.map(file => ({ id: crypto.randomUUID(), file })))}
+ onRetry={id => { const file = files.current.get(id); if (file) void upload([{ id, file }]); }}
+ onDismiss={id => { files.current.delete(id); setEntries(current => current.filter(entry => entry.id !== id)); }}>
+
+ :
}
+
+ ;
+}
diff --git a/ui/storybook/stories/artifact-upload/fixtures.ts b/ui/storybook/stories/artifact-upload/fixtures.ts
new file mode 100644
index 00000000000..0bf665ff862
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/fixtures.ts
@@ -0,0 +1,129 @@
+import type { IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
+import { queryKeys } from "@/lib/queryKeys";
+import { uploadedDocumentKey } from "@/lib/issue-artifact-upload";
+import { QueryClient } from "@tanstack/react-query";
+import { createIssue, storybookAgents } from "../../fixtures/paperclipData";
+
+export const issue = createIssue({
+ id: "artifact-upload-placeholder", identifier: "PAP-214", issueNumber: 214,
+ title: "Instance sleep — engineering handoff", status: "backlog",
+ assigneeAgentId: null, assigneeUserId: null, executionRunId: null,
+ checkoutRunId: null, executionLockedAt: null, currentExecutionWorkspace: null,
+ executionWorkspaceId: null, documentSummaries: [], workProducts: [],
+});
+
+export const handoffBody = `# Instance sleep: engineering handoff
+
+Paperclip should release idle database connections and let inactive instances sleep without losing scheduled work, incoming events, accounting, or recovery.
+
+## Start here
+
+The inventory records the remaining work. The implementation notes explain the patterns already in use. Quiet delivery queues alone do not establish whole-instance sleep.
+
+## References
+
+- [Loop inventory](?doc=loop-inventory)
+- [Implementation notes](?doc=implementation-notes)
+
+## Current direction
+
+Work caused by a committed change should run from that event. Work caused by the passage of time belongs in one instance-wide scheduler.
+
+## What comes next
+
+1. Migrate the remaining clock-driven work to the scheduler.
+2. Prove the full writer-admission and idle/resume boundary.
+3. Verify actual app and database sleep with the hosting providers.
+`;
+
+export function makeDocument(key: string, title: string, body: string): IssueDocument {
+ return {
+ id: `document-${key}`, issueId: issue.id, companyId: issue.companyId,
+ key, title, format: "markdown", body, latestRevisionId: `revision-${key}`,
+ latestRevisionNumber: 1, createdByAgentId: null, updatedByAgentId: null,
+ createdByUserId: "user-board", updatedByUserId: "user-board",
+ lockedAt: null, lockedByAgentId: null, lockedByUserId: null,
+ createdAt: new Date("2026-10-12T15:00:00Z"), updatedAt: new Date("2026-10-12T15:00:00Z"),
+ };
+}
+
+export const documents = [
+ makeDocument("core-handoff", "Instance sleep: engineering handoff", handoffBody),
+ makeDocument("loop-inventory", "Loop inventory", "# Loop inventory\n\n| Path | Pattern | Next action |\n| --- | --- | --- |\n| Durable delivery queues | Wake after commit | Verify idle boundary |\n| Routine schedules | Saved deadline | Register with scheduler |\n| Browser live events | Visibility-aware connection | Verify disconnect on hide |\n\n## Completion evidence\n\nRecord the source, change and verification result for each item."),
+ makeDocument("implementation-notes", "Implementation notes", "# Implementation notes\n\nDurable queue rows remain authoritative. A producer registers intent before its write; the database owner defers delivery wakes until the outer transaction settles.\n\n## Recovery\n\nKeep startup scans and saved retry deadlines. Empty, settled queues need no recurring scan.\n\n## Boundary\n\nSignals are process-local to one database owner and its dedicated child pools. Cross-process handoff needs separate fencing."),
+];
+
+export type PreviewState = "empty" | "populated" | "uploading" | "error" | "long";
+
+/** Enter through the real DOM drag handler so stories show the actual state. */
+export function showFileDrag(canvas: HTMLElement, scroll = false) {
+ const panel = canvas.querySelector('[aria-label="Artifact uploads"]');
+ if (!panel) throw new Error("Artifacts drop target is missing");
+ if (scroll) {
+ const viewport = panel.querySelector("[data-side-panel-content-viewport]");
+ if (viewport) viewport.scrollTop = viewport.scrollHeight;
+ }
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(["# New reference\n\nAdded through the Artifacts panel."], "new-reference.md", { type: "text/markdown" }));
+ // Include the tab header in the target rather than only the inner file list.
+ const target = panel.querySelector("header") ?? panel;
+ target.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: transfer }));
+}
+
+export function createPreviewClient(state: PreviewState) {
+ const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], state === "empty" ? [] : state === "long" ? [...documents, makeDocument("long-reference", "Database transaction recovery and instance sleep: detailed engineering reference with verification evidence", Array.from({ length: 20 }, (_, index) => `## Investigation ${index + 1}\n\nCheck the transaction outcome before retrying uncertain writes. Preserve the durable receipt and current task state.`).join("\n\n"))] : documents);
+ client.setQueryData(queryKeys.issues.attachments(issue.id), []);
+ client.setQueryData(queryKeys.issues.workProducts(issue.id), []);
+ client.setQueryData(queryKeys.issues.runs(issue.id), []);
+ client.setQueryData(queryKeys.agents.list(issue.companyId), storybookAgents);
+ return client;
+}
+
+// File bytes and mutations stay in this preview's memory. No live task changes.
+export async function addPreviewFile(client: QueryClient, file: File, id: string) {
+ if (/\.(md|markdown)$/i.test(file.name) || file.type === "text/markdown") {
+ const body = await file.text();
+ const current = client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? [];
+ const title = file.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
+ const key = uploadedDocumentKey(file.name, current.map(document => document.key));
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], [makeDocument(key, title, body), ...current]);
+ return;
+ }
+ client.setQueryData(["storybook-upload-file", id], file);
+ const contentPath = `/api/attachments/${id}/content`;
+ const product: IssueWorkProduct = {
+ id, companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId,
+ executionWorkspaceId: null, runtimeServiceId: null, type: "artifact", provider: "paperclip",
+ externalId: null, title: file.name, url: null, status: "active", reviewState: "none",
+ isPrimary: false, healthStatus: "healthy", summary: null, createdByRunId: null,
+ metadata: { attachmentId: id, contentType: file.type || "application/octet-stream", byteSize: file.size, contentPath, openPath: contentPath, downloadPath: `${contentPath}?download=1`, originalFilename: file.name },
+ createdAt: new Date("2026-10-12T15:01:00Z"), updatedAt: new Date("2026-10-12T15:01:00Z"),
+ };
+ client.setQueryData(queryKeys.issues.workProducts(issue.id), (current = []) => [product, ...current]);
+}
+
+export function installPreviewApi(client: QueryClient) {
+ const originalFetch = window.fetch;
+ const fixtureFetch: typeof window.fetch = async (input, init) => {
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
+ const base = `/api/issues/${issue.id}`;
+ const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)\/content$/);
+ if (attachmentMatch) {
+ const file = client.getQueryData(["storybook-upload-file", attachmentMatch[1]]);
+ if (file) return new Response(file, { headers: { "Content-Type": file.type || "application/octet-stream" } });
+ }
+ if (url.pathname === `${base}/work-products`) return Response.json(client.getQueryData(queryKeys.issues.workProducts(issue.id)) ?? []);
+ if (url.pathname === `${base}/attachments`) return Response.json([]);
+ if (url.pathname === `${base}/documents`) return Response.json(client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? []);
+ if (url.pathname.startsWith(`${base}/documents/`)) {
+ if (url.pathname.endsWith("/annotations")) return Response.json([]);
+ const key = decodeURIComponent(url.pathname.split("/").at(-1)!);
+ const document = (client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? []).find((doc) => doc.key === key);
+ return document ? Response.json(document) : Response.json({ error: "Document not found" }, { status: 404 });
+ }
+ return originalFetch(input, init);
+ };
+ window.fetch = fixtureFetch;
+ return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; };
+}
From ce15fe7c73e7a60ee05c13e74f93b140424b9bb7 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 08:15:21 -0500
Subject: [PATCH 03/23] refactor(heartbeat): extract task context preparation
(#15872)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat execution prepares task context before it selects an
environment and starts an adapter.
> - This preparation still occupied 319 lines inside the executor after
the earlier extractions.
> - It combines continuation summaries, comments, prompt variants,
communication guidance, and redaction.
> - This pull request moves that responsibility into one typed module
under `services/heartbeat/`.
> - The executor keeps admission, resource ownership, dispatch, and
cleanup ordering together.
> - The result makes the preparation phase easier to find and test
without changing execution behavior.
## Linked Issues or Issue Description
Refs #15825, #15807. This continues the incremental heartbeat extraction
series. A search found no duplicate task-context extraction.
**What existing behavior does this improve?**
Maintenance and review of heartbeat task context preparation.
**Current behavior**
The executor assembles prompts and trust-filtered context inline. This
hides its execution phases among preparation details.
**Proposed behavior**
Call `prepareHeartbeatTaskContext` at the same point in execution.
Preserve statement order, prompt contents, public exports, and the
shared run context object.
## What Changed
- Move task context assembly to
`server/src/services/heartbeat/task-context.ts`. Inputs group task
state, policy, and two existing service methods. Results preserve the
continuation and wake values used by later runtime selection and
dispatch.
- Keep company-scoped loading, quarantined-content filtering, secret
redaction, and revision hashing in their original order. Add no
execution resource acquisition.
- Add four public-path integration tests before the move. Cover prompt
variants and redacted revisions, quarantined summaries/comments, a
mixed-company wake batch, and stale caller fields.
- Extend the real process worker in the opt-in browser suite to check
persisted prompt variants and the description revision before it
completes its task.
- Update the module map and acceptance-test documentation.
- Reduce `heartbeat.ts` from 5,314 lines / 220,784 bytes to 4,989 lines
/ 206,650 bytes. The new module has 376 lines / 15,912 bytes.
## Verification
- Before the move: 11 existing prompt tests and all four new
characterization tests passed against the inline implementation.
- Normalized syntax comparison passed for all 319 moved lines and the
remaining orchestrator, including public exports. Imports and the typed
boundary were reviewed separately.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- The clean post-move focused run passed all 26 tests in five files,
with no skips: task-context characterization, task-markdown, Slack
invitation, accepted-plan workspace refresh, and run-preparation.
- The local `pnpm test:run` attempt was interrupted after database
failures while the Mac exhausted its shared-memory slots. All 15
affected native warm-ownership database cases passed in an isolated
rerun after the browser instance stopped; the other 666 cases in that
file were excluded by the test-name filter. The interrupted command is
not a full-suite pass. Full CI passed on
`6bf86cfdc07e7fe34015f7b719218639f4232510`: 55 successful checks and two
optional Storybook checks skipped. Greptile reviewed the latest commit
at 5/5; its one earlier worker-test finding is fixed and resolved.
- Browser E2E passed both scenarios on the final code: persistent
timeouts stop after three runs and survive reload; one timeout recovers
without manual Retry, verifies task-context prompt variants/revision and
preserved workspace files in a real process, then completes through the
run-scoped API. The first recovery attempt exposed an incorrect new test
assertion because the fixture title equals its description. The worker
now checks the description section specifically; the corrected recovery
scenario passed in 55.9 seconds. The original failure trace was
preserved. These tests use a deterministic process adapter and make no
live model calls.
## Risks
This is a behavior-preserving extraction. The main risks are changed
closure bindings, missing return values, or changed preparation order.
Typed inputs, public-path tests, and syntax comparison cover those
risks. The run context is still the same mutable object. The helper
acquires no leases or processes. It introduces no schema, API, or prompt
policy changes.
The first post-move database run skipped cases due to host resource
exhaustion. All 26 focused cases passed on the subsequent clean run.
Browser E2E passed after the worker assertion fix. The full local suite
remains incomplete. All 15 native warm-ownership database cases passed
on their isolated rerun; The full CI test suite passed on the final
commit.
## Model Used
OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code execution, and test tools. The host does not expose the exact
deployment model ID 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
- [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
---
doc/DEVELOPING.md | 13 +-
.../__tests__/heartbeat-task-context.test.ts | 177 +++++++++
server/src/services/heartbeat.ts | 343 +---------------
server/src/services/heartbeat/task-context.ts | 376 ++++++++++++++++++
tests/e2e/workspace-bootstrap/README.md | 4 +-
tests/e2e/workspace-bootstrap/worker.mjs | 12 +
6 files changed, 589 insertions(+), 336 deletions(-)
create mode 100644 server/src/__tests__/heartbeat-task-context.test.ts
create mode 100644 server/src/services/heartbeat/task-context.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index ae811577a16..79d71fb4012 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1279,9 +1279,20 @@ When a workspace service runs Paperclip for browser OAuth QA, configure its `exp
Keep relevant heartbeat extractions and their focused tests in `server/src/services/heartbeat/`.
Task assignment Markdown is rendered by `server/src/services/heartbeat/task-markdown.ts`.
-`heartbeat.ts` calls the renderer and re-exports it for existing callers. Keep prompt
+`heartbeat.ts` re-exports it for existing callers. Keep prompt
formatting changes in the renderer and its tests, separate from run orchestration.
+Task context assembly is in `server/src/services/heartbeat/task-context.ts`.
+`prepareHeartbeatTaskContext` rebuilds prompt fields on the current run context:
+continuation summaries, wake comments, task plans, communication guidance, and
+full/compact assignment Markdown. It preserves quarantine and secret redaction
+before computing turn-context revisions. The executor supplies the already-loaded
+issue, session, and trust policy, and retains admission, resource ownership, and
+cleanup. Public-path characterization tests are in
+`server/src/__tests__/heartbeat-task-context.test.ts`; the workspace-bootstrap
+browser fixture also checks the persisted prompt variants and description revision
+from a real process adapter before completing its task.
+
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
diff --git a/server/src/__tests__/heartbeat-task-context.test.ts b/server/src/__tests__/heartbeat-task-context.test.ts
new file mode 100644
index 00000000000..bca00d2a403
--- /dev/null
+++ b/server/src/__tests__/heartbeat-task-context.test.ts
@@ -0,0 +1,177 @@
+import { createHash, randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
+import { FAST_RESPONSE_AGENT_GUIDANCE } from "@paperclipai/shared";
+import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
+import { heartbeatService } from "../services/heartbeat.js";
+import { documentService } from "../services/documents.js";
+import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "../services/issue-continuation-summary.js";
+import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
+import { buildLowTrustSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../services/source-trust.js";
+import { REDACTED_EVENT_VALUE } from "../redaction.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+const describePostgres = support.supported ? describe : describe.skip;
+if (!support.supported) console.warn(`Skipping heartbeat task context tests: ${support.reason}`);
+
+describePostgres("heartbeat task context delivered to an adapter", () => {
+ const adapterType = "task_context_test";
+ const captured = new Map();
+ let temporary: Awaited>;
+ let db: ReturnType;
+ let heartbeat: ReturnType;
+
+ beforeAll(async () => {
+ temporary = await startEmbeddedPostgresTestDatabase("heartbeat-task-context-");
+ db = createDb(temporary.connectionString);
+ heartbeat = heartbeatService(db);
+ registerServerAdapter({
+ type: adapterType,
+ supportsLocalAgentJwt: false,
+ execute: async (input) => {
+ captured.set(input.runId, input);
+ return { exitCode: 0, signal: null, timedOut: false, sessionId: "context-session", resultJson: {} };
+ },
+ testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
+ });
+ }, 30_000);
+
+ afterAll(async () => {
+ await heartbeat?.drainActiveRunExecutions();
+ unregisterServerAdapter(adapterType);
+ await db?.$client.end({ timeout: 0 });
+ await temporary?.cleanup();
+ });
+
+ async function seed() {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Task context fixture", issuePrefix: `T${companyId.slice(0, 6)}` });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Context reader", adapterType, status: "idle" });
+ await db.insert(issues).values({
+ id: issueId, companyId, title: "Task context fixture", description: "Assignment description canary",
+ status: "todo", assigneeAgentId: agentId, responsibleUserId: "context-user", issueNumber: 1,
+ });
+ return { companyId, agentId, issueId };
+ }
+
+ async function execute(f: Awaited>, context: Record) {
+ const run = await heartbeat.wakeup(f.agentId, {
+ source: "on_demand", triggerDetail: "manual", reason: "issue_assigned",
+ payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId, ...context },
+ });
+ expect(run).not.toBeNull();
+ await heartbeat.drainActiveRunExecutions();
+ const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
+ expect(finished?.status, finished?.error ?? undefined).toBe("succeeded");
+ expect(captured.has(run!.id)).toBe(true);
+ return captured.get(run!.id)!;
+ }
+
+ it("keeps assignment, wake, compact, and revision context aligned after secret redaction", async () => {
+ const f = await seed();
+ const secret = "task-context-secret-canary";
+ const description = `Assignment description canary ${secret}`;
+ await db.update(issues).set({ description }).where(eq(issues.id, f.issueId));
+ const previousRunId = randomUUID();
+ await db.insert(heartbeatRuns).values({
+ id: previousRunId, companyId: f.companyId, agentId: f.agentId,
+ invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: f.issueId },
+ });
+ await createRunSecretRedactionRegistry(db).register(f.companyId, previousRunId, secret);
+ const commentId = randomUUID();
+ await db.insert(issueComments).values({
+ id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
+ body: `Wake comment canary ${secret}`,
+ });
+ const { context } = await execute(f, {
+ wakeCommentId: commentId, wakeCommentIds: [commentId],
+ paperclipTaskCommunicationGuidance: "FORGED caller guidance", paperclipSkillTest: { directive: "FORGED skill" },
+ paperclipContinuationSummary: { body: "FORGED summary" },
+ });
+ expect(context.paperclipTaskMarkdown).toContain("Assignment description canary");
+ expect(context.paperclipTaskMarkdown).toContain("Wake comment canary");
+ expect(context.paperclipTaskMarkdownAssignment).toContain("Assignment description canary");
+ expect(context.paperclipTaskMarkdownAssignment).not.toContain("Wake comment canary");
+ expect(context.paperclipTaskMarkdownCompact).not.toContain("Assignment description canary");
+ expect(context.paperclipTaskMarkdownCompact).toContain("Wake comment canary");
+ expect(context.paperclipTaskMarkdownAssignmentCompact).not.toContain("Assignment description canary");
+ expect(context.paperclipTaskMarkdownAssignmentCompact).not.toContain("Wake comment canary");
+ for (const key of ["paperclipIssue", "paperclipWakeComment", "paperclipTaskMarkdown", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignmentCompact"]) {
+ expect(JSON.stringify(context[key])).not.toContain(secret);
+ }
+ expect(context.paperclipTaskCommunicationGuidance).toBe(FAST_RESPONSE_AGENT_GUIDANCE);
+ expect(context.paperclipSkillTest).toBeUndefined();
+ expect(context.paperclipContinuationSummary).toBeUndefined();
+ const digest = (value: string) => createHash("sha256").update(value).digest("hex");
+ expect(context.paperclipTurnContext).toMatchObject({
+ version: 1,
+ assignment: { owner: "task_markdown", description: { id: f.issueId, revision: digest(`Assignment description canary ${REDACTED_EVENT_VALUE}`) } },
+ events: { owner: "wake_prompt", comments: [{ id: commentId, revision: digest(`Wake comment canary ${REDACTED_EVENT_VALUE}`) }] },
+ });
+ });
+
+ it("quarantines low-trust summaries and comments before preparing higher-trust prompts", async () => {
+ const f = await seed();
+ const sourceTrust = buildLowTrustSourceTrust({ issueId: f.issueId });
+ await documentService(db).upsertIssueDocument({
+ issueId: f.issueId, key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, format: "markdown",
+ body: "QUARANTINED summary canary", sourceTrust,
+ });
+ const commentId = randomUUID();
+ await db.insert(issueComments).values({
+ id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
+ body: "QUARANTINED comment canary", sourceTrust,
+ });
+ const { context } = await execute(f, { wakeCommentId: commentId, wakeCommentIds: [commentId] });
+ expect(context.paperclipContinuationSummary).toMatchObject({ body: LOW_TRUST_QUARANTINED_BODY });
+ expect(context.paperclipWakeComment).toMatchObject({ body: LOW_TRUST_QUARANTINED_BODY, presentation: null, metadata: null });
+ expect(context.paperclipTaskMarkdown).toContain(LOW_TRUST_QUARANTINED_BODY);
+ for (const key of ["paperclipContinuationSummary", "executionContinuation", "paperclipWakeComment", "paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipWakePayload"]) {
+ expect(JSON.stringify(context[key]) ?? "").not.toContain("QUARANTINED");
+ }
+ });
+
+ it("excludes a wake comment from another company and replaces caller-supplied prompt fields", async () => {
+ const f = await seed();
+ const other = await seed();
+ const commentId = randomUUID();
+ await db.insert(issueComments).values({
+ id: commentId, companyId: other.companyId, issueId: other.issueId, authorType: "user", authorUserId: "other-user",
+ body: "OTHER COMPANY private comment",
+ });
+ const ownCommentId = randomUUID();
+ await db.insert(issueComments).values({
+ id: ownCommentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
+ body: "Authorized wake comment",
+ });
+ const { context } = await execute(f, {
+ wakeCommentId: ownCommentId, wakeCommentIds: [ownCommentId, commentId],
+ paperclipWakeComment: { body: "FORGED comment" }, paperclipTaskMarkdown: "FORGED prompt",
+ paperclipTaskMarkdownAssignment: "FORGED assignment", paperclipTaskMarkdownCompact: "FORGED compact",
+ paperclipTaskMarkdownAssignmentCompact: "FORGED compact assignment",
+ });
+ expect(context.paperclipWakeComment).toMatchObject({ id: ownCommentId, body: "Authorized wake comment" });
+ expect(context.paperclipTaskMarkdown).toContain("Assignment description canary");
+ for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignmentCompact", "paperclipWakePayload"]) {
+ expect(JSON.stringify(context[key]) ?? "").not.toContain("FORGED");
+ expect(JSON.stringify(context[key]) ?? "").not.toContain("OTHER COMPANY");
+ }
+ expect(context.paperclipTurnContext).toMatchObject({ events: { comments: [{ id: ownCommentId }] } });
+ });
+
+ it("clears stale wake comments and continuation data when the task has none", async () => {
+ const f = await seed();
+ const { context } = await execute(f, {
+ paperclipWakeComment: { body: "FORGED comment" },
+ paperclipContinuationSummary: { body: "FORGED summary" },
+ paperclipSkillTest: { directive: "FORGED skill" },
+ paperclipTurnContext: { events: { comments: [{ id: "FORGED comment" }] } },
+ });
+ expect(context.paperclipWakeComment).toBeUndefined();
+ expect(context.paperclipContinuationSummary).toBeUndefined();
+ expect(context.paperclipSkillTest).toBeUndefined();
+ expect(context.paperclipTurnContext).toMatchObject({ events: { comments: [] } });
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 4c2cd3da028..bcbcdb0a648 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js";
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
import { executeHeartbeatRuntime, NativeSessionResumeScheduledError, NativeWorkspaceFinalizeScheduledError } from "./heartbeat/runtime-execution.js";
@@ -13,7 +14,6 @@ import {
createHeartbeatScheduling,
formatIssueIdentifierLink,
} from "./heartbeat/scheduling.js";
-import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
import { agentExecutionsHaveStopped } from "./agent-execution-stop.js";
import {
cancelHeartbeatNativeRun,
@@ -115,7 +115,6 @@ import {
attestReviewedExternalChatRun,
resolveAcceptedPlanWakeRoutingDecision,
clearInteractionContinuationWakeContext,
- buildPaperclipWakePayload,
PAPERCLIP_WAKE_PAYLOAD_KEY,
resolveRunScopedMentionedSkillKeys,
resolveExecutionRunAdapterConfig,
@@ -220,7 +219,6 @@ export {
boundHeartbeatRunEventPayloadForStorage,
compactRunLogChunk,
} from "./heartbeat/run-log.js";
-import { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
export { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
import { decisionModelService } from "./decision-models.js";
@@ -236,12 +234,9 @@ import {
} from "./native-runtime/native-cancellation-request.js";
import {
prepareChatCompletionTurn,
- chatCompletionInstruction,
} from "./chat-completion-delivery.js";
import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
-import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
-
import {
NativeWorkspaceFinalizationBusyError,
NativeWorkspaceFinalizationOwnershipLostError,
@@ -251,7 +246,6 @@ import { reserveRunBudget } from "./budget-reservations.js";
import { accountRunCost, createCostAccountingReconciler } from "./run-cost-accounting.js";
import { toolActionDeliveryService } from "./tool-action-delivery.js";
-import { liveVoiceExecutionGuidance } from "./voice/voice-execution-guidance.js";
import { githubBotConnectionIdsForRun } from "./chat-github-tools.js";
import {
@@ -260,7 +254,6 @@ import {
settleConversationTurn,
} from "./agent-conversations.js";
import { withAdapterExecutionPhase } from "@paperclipai/adapter-utils/execution-phase";
-import { getConversationConfirmationContext } from "./conversation-confirmation-context.js";
import {
recordNativeLocalProcessStop,
} from "./native-local-process-stop.js";
@@ -300,7 +293,7 @@ import {
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
-import { buildExecutionContinuation, StaleExecutionContinuationError } from "./execution-continuation.js";
+import { StaleExecutionContinuationError } from "./execution-continuation.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import {
@@ -370,8 +363,6 @@ import {
queuedCommentIdsFromRunContext,
} from "./issue-queued-comment-queue.js";
-import { getTaskPlanContext } from "./task-plan-context.js";
-
import {
claimWarmNativeInstructionCopy,
nativeSessionWorkspaceScope,
@@ -471,7 +462,6 @@ import {
} from "./issue-tree-control.js";
import {
continuationSummaryParksExecutor,
- getIssueContinuationSummaryDocument,
} from "./issue-continuation-summary.js";
import {
@@ -543,10 +533,6 @@ import {
DIRECT_NON_INVOKABLE_STATUSES,
} from "./agent-invokability.js";
-import {
- redactQuarantinedBodyForHigherTrust,
- sanitizeQuarantinedCommentForHigherTrust,
-} from "./source-trust.js";
import {
redactCurrentUserText,
} from "../log-redaction.js";
@@ -2701,325 +2687,14 @@ export function heartbeatService(
await db.update(heartbeatRuns).set({
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ ledgerScope: runLedgerScope })}::jsonb`,
}).where(eq(heartbeatRuns.id, run.id));
- const continuationSummary = issueRef && !isConversation(issueContext)
- ? await getIssueContinuationSummaryDocument(db, issueRef.id)
- : null;
- const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
- const safeContinuationSummary =
- continuationSummary && !exposeLowTrustRaw
- ? redactQuarantinedBodyForHigherTrust(continuationSummary)
- : continuationSummary;
- const safeWakeCommentContext =
- wakeCommentContext && !exposeLowTrustRaw
- ? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
- : wakeCommentContext;
- const issueAncestors = issueRef
- ? await issuesSvc.getAncestors(issueRef.id)
- : [];
- if (continuationSummary) {
- context.paperclipContinuationSummary = {
- key: safeContinuationSummary!.key,
- title: safeContinuationSummary!.title,
- body: safeContinuationSummary!.body,
- sourceTrust: safeContinuationSummary!.sourceTrust ?? null,
- updatedAt: safeContinuationSummary!.updatedAt.toISOString(),
- };
- } else {
- delete context.paperclipContinuationSummary;
- }
- const pinnedSkillTestContext =
- issueRef?.workMode === "skill_test"
- ? await getPinnedSkillTestContext(agent.companyId, issueRef.id)
- : null;
- if (pinnedSkillTestContext) {
- context.paperclipSkillTest = {
- ...pinnedSkillTestContext,
- directive:
- "Use this pinned file inventory as the exact skill revision under test, regardless of synced runtime skills.",
- };
- } else {
- delete context.paperclipSkillTest;
- }
- const executionContinuation =
- issueRef && !isConversation(issueContext) && issueContext?.assigneeAgentId === agent.id
- ? await buildExecutionContinuation({
- db,
- companyId: agent.companyId,
- issueId: issueRef.id,
- agentId: agent.id,
- runId: run.id,
- context,
- previousContextRunId: taskSession?.lastRunId,
- summary: safeContinuationSummary?.body ?? null,
- exposeLowTrustRaw,
- })
- : null;
- context.executionContinuation = executionContinuation;
- const paperclipWakePayload = await buildPaperclipWakePayload({
- db,
- companyId: agent.companyId,
- agentId: agent.id,
- runId: run.id,
- contextSnapshot: context,
- continuationSummary,
- issueSummary: issueRef
- ? {
- id: issueRef.id,
- identifier: issueRef.identifier,
- title: issueRef.title,
- description: issueContext?.description ?? null,
- status: issueRef.status,
- priority: issueRef.priority,
- workMode: issueRef.workMode,
- projectId: issueRef.projectId,
- executionPolicy: issueContext?.executionPolicy ?? null,
- }
- : null,
- exposeLowTrustRaw,
- simplifiedEnglishInteractions:
- experimentalInstanceSettings.enableSimplifiedEnglishInteractions ===
- true,
+ const {
+ continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext,
+ } = await prepareHeartbeatTaskContext(db, {
+ run, agent,
+ task: { issueRef, issueContext, wakeCommentContext, context, taskSession },
+ policy: { trustPreset, experimentalInstanceSettings },
+ services: { issuesSvc, getPinnedSkillTestContext },
});
- if (paperclipWakePayload) {
- context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
- } else {
- delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
- }
- const safeWakeComments = (paperclipWakePayload?.comments ?? []).flatMap(
- (comment) =>
- typeof comment.id === "string" && typeof comment.body === "string"
- ? [
- {
- id: comment.id,
- body: fastResponseHistoryBody({ body: comment.body, origin: typeof comment.origin === "string" ? comment.origin : null }),
- attachments: Array.isArray(comment.attachments)
- ? comment.attachments.flatMap((attachment) => {
- const descriptor = parseObject(attachment);
- const id = readNonEmptyString(descriptor.id);
- const filename = readNonEmptyString(
- descriptor.filename,
- );
- const contentType = readNonEmptyString(
- descriptor.contentType,
- );
- const contentPath = readNonEmptyString(
- descriptor.contentPath,
- );
- const byteSize = descriptor.byteSize;
- return id &&
- filename &&
- contentType &&
- contentPath &&
- typeof byteSize === "number"
- ? [
- {
- id,
- filename,
- contentType,
- byteSize,
- contentPath,
- },
- ]
- : [];
- })
- : [],
- },
- ]
- : [],
- );
- // Always replace caller-supplied context with the immutable, company-scoped
- // conversation snapshot. It belongs only to the endpoint's assigned agent.
- const liveVoiceGuidance = issueContext?.chatAssignedAgentId === agent.id && issueContext.chatProvider === "speko"
- ? await liveVoiceExecutionGuidance(db, { companyId: agent.companyId, issueId: issueContext.id, agentId: agent.id })
- : null;
- context.paperclipTaskCommunicationGuidance =
- issueContext?.chatAssignedAgentId === agent.id
- ? issueContext.chatCommunicationGuidance
- : null;
- const taskMarkdownInput = {
- conversationConfirmations: issueRef && isConversation(issueContext)
- ? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
- : null,
- issue: issueRef
- ? {
- id: issueRef.id,
- identifier: issueRef.identifier,
- title: issueRef.title,
- titleNeedsGeneration: issueContext?.titleNeedsGeneration,
- workMode: issueRef.workMode,
- conversationAgentId: issueContext?.conversationAgentId,
- description: issueRef.description,
- }
- : null,
- ancestors: issueAncestors,
- wakeComment: safeWakeCommentContext,
- wakeComments: safeWakeComments,
- attachmentOmissions: paperclipWakePayload?.attachmentOmissions,
- externalChatProvider: paperclipWakePayload?.externalChatProvider,
- slackCommand: issueContext?.chatAssignedAgentId === agent.id
- ? issueContext.chatSlackCommand
- : null,
- nativeRunner: agent.adapterType === "paperclip_runner",
- interaction: {
- kind: readNonEmptyString(context.interactionKind),
- status: readNonEmptyString(context.interactionStatus),
- },
- planReview: paperclipWakePayload?.planReviewContext?.interaction
- ? {
- status: paperclipWakePayload.planReviewContext.interaction.status,
- reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
- }
- : null,
- acceptedPlanContinuation:
- readNonEmptyString(context.workspaceRefreshReason) ===
- "accepted_plan_confirmation" &&
- Object.keys(parseObject(context.acceptedPlanWakeRouting)).length ===
- 0,
- acceptedPlan: (() => {
- const accepted = parseObject(
- parseObject(context.planReviewInteraction).acceptedTargetRevision,
- );
- const revisionId = readNonEmptyString(accepted.revisionId);
- if (!revisionId) return null;
- return {
- documentId: readNonEmptyString(accepted.documentId),
- revisionId,
- revisionNumber:
- typeof accepted.revisionNumber === "number"
- ? accepted.revisionNumber
- : null,
- };
- })(),
- };
- const taskPlan = issueRef && !isConversation(issueContext)
- ? await getTaskPlanContext({
- db,
- companyId: agent.companyId,
- issueId: issueRef.id,
- approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
- exposeLowTrustRaw,
- })
- : null;
- // Unlike the frozen initial communication preferences, live-call context
- // must also reach resumed turns. Rebuild it from server records each wake.
- const liveVoiceTaskPrefix = liveVoiceGuidance ? `${liveVoiceGuidance}\n\n` : "";
- let taskMarkdown = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan }) + chatCompletionInstruction(context);
- let taskMarkdownAssignment = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
- ...taskMarkdownInput,
- taskPlan,
- includeWakeComments: false,
- }) + chatCompletionInstruction(context);
- const taskMarkdownCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
- ...taskMarkdownInput,
- taskPlan,
- includeDescription: false,
- }) + chatCompletionInstruction(context);
- const taskMarkdownAssignmentCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
- ...taskMarkdownInput,
- taskPlan,
- includeDescription: false,
- includeWakeComments: false,
- }) + chatCompletionInstruction(context);
- if (issueRef) {
- context.paperclipIssue = {
- id: issueRef.id,
- identifier: issueRef.identifier,
- title: issueRef.title,
- description: isConversation(issueContext) ? null : issueRef.description,
- workMode: issueRef.workMode,
- };
- } else {
- delete context.paperclipIssue;
- }
- if (wakeCommentContext) {
- context.paperclipWakeComment = safeWakeCommentContext;
- } else {
- delete context.paperclipWakeComment;
- }
- if (taskMarkdown) {
- context.paperclipTaskMarkdown = taskMarkdown;
- } else {
- delete context.paperclipTaskMarkdown;
- }
- if (taskMarkdownAssignment) {
- context.paperclipTaskMarkdownAssignment = taskMarkdownAssignment;
- } else {
- delete context.paperclipTaskMarkdownAssignment;
- }
- if (taskMarkdownCompact && taskMarkdownCompact !== taskMarkdown) {
- context.paperclipTaskMarkdownCompact = taskMarkdownCompact;
- } else {
- delete context.paperclipTaskMarkdownCompact;
- }
- if (taskMarkdownAssignmentCompact && taskMarkdownAssignmentCompact !== taskMarkdownAssignment) {
- context.paperclipTaskMarkdownAssignmentCompact = taskMarkdownAssignmentCompact;
- } else {
- delete context.paperclipTaskMarkdownAssignmentCompact;
- }
- context.paperclipTaskCommunicationGuidance = [context.paperclipTaskCommunicationGuidance, FAST_RESPONSE_AGENT_GUIDANCE].filter(Boolean).join("\n\n");
- if (issueRef) {
- const redactedWakeContext = await createRunSecretRedactionRegistry(
- db,
- ).redactForIssue(agent.companyId, issueRef.id, {
- paperclipIssue: context.paperclipIssue,
- paperclipWakeComment: context.paperclipWakeComment,
- paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance,
- paperclipTaskMarkdown: context.paperclipTaskMarkdown,
- paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact,
- paperclipTaskMarkdownAssignment: context.paperclipTaskMarkdownAssignment,
- paperclipTaskMarkdownAssignmentCompact: context.paperclipTaskMarkdownAssignmentCompact,
- });
- context.paperclipIssue = redactedWakeContext.paperclipIssue;
- context.paperclipTaskCommunicationGuidance = redactedWakeContext.paperclipTaskCommunicationGuidance;
- if (redactedWakeContext.paperclipWakeComment) {
- context.paperclipWakeComment =
- redactedWakeContext.paperclipWakeComment;
- }
- if (redactedWakeContext.paperclipTaskMarkdown) {
- context.paperclipTaskMarkdown =
- redactedWakeContext.paperclipTaskMarkdown;
- }
- if (redactedWakeContext.paperclipTaskMarkdownCompact) {
- context.paperclipTaskMarkdownCompact =
- redactedWakeContext.paperclipTaskMarkdownCompact;
- }
- if (redactedWakeContext.paperclipTaskMarkdownAssignment) {
- context.paperclipTaskMarkdownAssignment =
- redactedWakeContext.paperclipTaskMarkdownAssignment;
- }
- if (redactedWakeContext.paperclipTaskMarkdownAssignmentCompact) {
- context.paperclipTaskMarkdownAssignmentCompact =
- redactedWakeContext.paperclipTaskMarkdownAssignmentCompact;
- }
- }
- if (issueRef) {
- const digest = (value: string | null | undefined) =>
- value?.trim()
- ? createHash("sha256")
- .update(value.trim().replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""))
- .digest("hex")
- : null;
- const redactedIssue = parseObject(context.paperclipIssue);
- context.paperclipTurnContext = {
- version: 1,
- assignment: {
- owner: "task_markdown",
- description: {
- id: issueRef.id,
- revision: digest(readNonEmptyString(redactedIssue.description)),
- },
- },
- events: {
- owner: "wake_prompt",
- comments: safeWakeComments.map((comment) => ({
- id: comment.id,
- revision: digest(comment.body),
- })),
- },
- } satisfies PaperclipTurnContext;
- } else {
- delete context.paperclipTurnContext;
- }
const {
persistedRunnerProfile,
persistedNativeExecutionInput,
diff --git a/server/src/services/heartbeat/task-context.ts b/server/src/services/heartbeat/task-context.ts
new file mode 100644
index 00000000000..56210bd00e1
--- /dev/null
+++ b/server/src/services/heartbeat/task-context.ts
@@ -0,0 +1,376 @@
+import { createHash } from "node:crypto";
+import type { Db, agents, agentTaskSessions, heartbeatRuns, issueComments } from "@paperclipai/db";
+import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
+import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
+import { parseObject } from "../../adapters/utils.js";
+import { isConversation } from "../agent-conversations.js";
+import { chatCompletionInstruction } from "../chat-completion-delivery.js";
+import { getConversationConfirmationContext } from "../conversation-confirmation-context.js";
+import { buildExecutionContinuation } from "../execution-continuation.js";
+import { getIssueContinuationSummaryDocument } from "../issue-continuation-summary.js";
+import type { issueService } from "../issues.js";
+import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
+import { redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
+import { getTaskPlanContext } from "../task-plan-context.js";
+import type { TrustPresetResolution } from "../trust-preset-resolver.js";
+import { liveVoiceExecutionGuidance } from "../voice/voice-execution-guidance.js";
+import { buildPaperclipWakePayload, PAPERCLIP_WAKE_PAYLOAD_KEY, type createHeartbeatRunPreparation } from "./run-preparation.js";
+import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
+
+type RunPreparation = ReturnType;
+type IssueContext = NonNullable>>;
+
+export interface HeartbeatTaskContextInput {
+ run: Pick;
+ agent: Pick;
+ task: {
+ issueRef: Pick | null;
+ issueContext: IssueContext | null;
+ wakeCommentContext: Pick | null;
+ context: Record;
+ taskSession: Pick | null;
+ };
+ policy: {
+ trustPreset: Pick;
+ experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean };
+ };
+ services: {
+ issuesSvc: Pick, "getAncestors">;
+ getPinnedSkillTestContext: RunPreparation["getPinnedSkillTestContext"];
+ };
+}
+
+function readNonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
+}
+
+/** Rebuilds task prompt fields on the run's context; acquires no execution resources. */
+export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskContextInput) {
+ const { run, agent } = input;
+ const { issueRef, issueContext, wakeCommentContext, context, taskSession } = input.task;
+ const { trustPreset, experimentalInstanceSettings } = input.policy;
+ const { issuesSvc, getPinnedSkillTestContext } = input.services;
+ const continuationSummary = issueRef && !isConversation(issueContext)
+ ? await getIssueContinuationSummaryDocument(db, issueRef.id)
+ : null;
+ const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
+ const safeContinuationSummary =
+ continuationSummary && !exposeLowTrustRaw
+ ? redactQuarantinedBodyForHigherTrust(continuationSummary)
+ : continuationSummary;
+ const safeWakeCommentContext =
+ wakeCommentContext && !exposeLowTrustRaw
+ ? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
+ : wakeCommentContext;
+ const issueAncestors = issueRef
+ ? await issuesSvc.getAncestors(issueRef.id)
+ : [];
+ if (continuationSummary) {
+ context.paperclipContinuationSummary = {
+ key: safeContinuationSummary!.key,
+ title: safeContinuationSummary!.title,
+ body: safeContinuationSummary!.body,
+ sourceTrust: safeContinuationSummary!.sourceTrust ?? null,
+ updatedAt: safeContinuationSummary!.updatedAt.toISOString(),
+ };
+ } else {
+ delete context.paperclipContinuationSummary;
+ }
+ const pinnedSkillTestContext =
+ issueRef?.workMode === "skill_test"
+ ? await getPinnedSkillTestContext(agent.companyId, issueRef.id)
+ : null;
+ if (pinnedSkillTestContext) {
+ context.paperclipSkillTest = {
+ ...pinnedSkillTestContext,
+ directive:
+ "Use this pinned file inventory as the exact skill revision under test, regardless of synced runtime skills.",
+ };
+ } else {
+ delete context.paperclipSkillTest;
+ }
+ const executionContinuation =
+ issueRef && !isConversation(issueContext) && issueContext?.assigneeAgentId === agent.id
+ ? await buildExecutionContinuation({
+ db,
+ companyId: agent.companyId,
+ issueId: issueRef.id,
+ agentId: agent.id,
+ runId: run.id,
+ context,
+ previousContextRunId: taskSession?.lastRunId,
+ summary: safeContinuationSummary?.body ?? null,
+ exposeLowTrustRaw,
+ })
+ : null;
+ context.executionContinuation = executionContinuation;
+ const paperclipWakePayload = await buildPaperclipWakePayload({
+ db,
+ companyId: agent.companyId,
+ agentId: agent.id,
+ runId: run.id,
+ contextSnapshot: context,
+ continuationSummary,
+ issueSummary: issueRef
+ ? {
+ id: issueRef.id,
+ identifier: issueRef.identifier,
+ title: issueRef.title,
+ description: issueContext?.description ?? null,
+ status: issueRef.status,
+ priority: issueRef.priority,
+ workMode: issueRef.workMode,
+ projectId: issueRef.projectId,
+ executionPolicy: issueContext?.executionPolicy ?? null,
+ }
+ : null,
+ exposeLowTrustRaw,
+ simplifiedEnglishInteractions:
+ experimentalInstanceSettings.enableSimplifiedEnglishInteractions ===
+ true,
+ });
+ if (paperclipWakePayload) {
+ context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
+ } else {
+ delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
+ }
+ const safeWakeComments = (paperclipWakePayload?.comments ?? []).flatMap(
+ (comment) =>
+ typeof comment.id === "string" && typeof comment.body === "string"
+ ? [
+ {
+ id: comment.id,
+ body: fastResponseHistoryBody({ body: comment.body, origin: typeof comment.origin === "string" ? comment.origin : null }),
+ attachments: Array.isArray(comment.attachments)
+ ? comment.attachments.flatMap((attachment) => {
+ const descriptor = parseObject(attachment);
+ const id = readNonEmptyString(descriptor.id);
+ const filename = readNonEmptyString(
+ descriptor.filename,
+ );
+ const contentType = readNonEmptyString(
+ descriptor.contentType,
+ );
+ const contentPath = readNonEmptyString(
+ descriptor.contentPath,
+ );
+ const byteSize = descriptor.byteSize;
+ return id &&
+ filename &&
+ contentType &&
+ contentPath &&
+ typeof byteSize === "number"
+ ? [
+ {
+ id,
+ filename,
+ contentType,
+ byteSize,
+ contentPath,
+ },
+ ]
+ : [];
+ })
+ : [],
+ },
+ ]
+ : [],
+ );
+ // Always replace caller-supplied context with the immutable, company-scoped
+ // conversation snapshot. It belongs only to the endpoint's assigned agent.
+ const liveVoiceGuidance = issueContext?.chatAssignedAgentId === agent.id && issueContext.chatProvider === "speko"
+ ? await liveVoiceExecutionGuidance(db, { companyId: agent.companyId, issueId: issueContext.id, agentId: agent.id })
+ : null;
+ context.paperclipTaskCommunicationGuidance =
+ issueContext?.chatAssignedAgentId === agent.id
+ ? issueContext.chatCommunicationGuidance
+ : null;
+ const taskMarkdownInput = {
+ conversationConfirmations: issueRef && isConversation(issueContext)
+ ? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
+ : null,
+ issue: issueRef
+ ? {
+ id: issueRef.id,
+ identifier: issueRef.identifier,
+ title: issueRef.title,
+ titleNeedsGeneration: issueContext?.titleNeedsGeneration,
+ workMode: issueRef.workMode,
+ conversationAgentId: issueContext?.conversationAgentId,
+ description: issueRef.description,
+ }
+ : null,
+ ancestors: issueAncestors,
+ wakeComment: safeWakeCommentContext,
+ wakeComments: safeWakeComments,
+ attachmentOmissions: paperclipWakePayload?.attachmentOmissions,
+ externalChatProvider: paperclipWakePayload?.externalChatProvider,
+ slackCommand: issueContext?.chatAssignedAgentId === agent.id
+ ? issueContext.chatSlackCommand
+ : null,
+ nativeRunner: agent.adapterType === "paperclip_runner",
+ interaction: {
+ kind: readNonEmptyString(context.interactionKind),
+ status: readNonEmptyString(context.interactionStatus),
+ },
+ planReview: paperclipWakePayload?.planReviewContext?.interaction
+ ? {
+ status: paperclipWakePayload.planReviewContext.interaction.status,
+ reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
+ }
+ : null,
+ acceptedPlanContinuation:
+ readNonEmptyString(context.workspaceRefreshReason) ===
+ "accepted_plan_confirmation" &&
+ Object.keys(parseObject(context.acceptedPlanWakeRouting)).length ===
+ 0,
+ acceptedPlan: (() => {
+ const accepted = parseObject(
+ parseObject(context.planReviewInteraction).acceptedTargetRevision,
+ );
+ const revisionId = readNonEmptyString(accepted.revisionId);
+ if (!revisionId) return null;
+ return {
+ documentId: readNonEmptyString(accepted.documentId),
+ revisionId,
+ revisionNumber:
+ typeof accepted.revisionNumber === "number"
+ ? accepted.revisionNumber
+ : null,
+ };
+ })(),
+ };
+ const taskPlan = issueRef && !isConversation(issueContext)
+ ? await getTaskPlanContext({
+ db,
+ companyId: agent.companyId,
+ issueId: issueRef.id,
+ approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
+ exposeLowTrustRaw,
+ })
+ : null;
+ // Unlike the frozen initial communication preferences, live-call context
+ // must also reach resumed turns. Rebuild it from server records each wake.
+ const liveVoiceTaskPrefix = liveVoiceGuidance ? `${liveVoiceGuidance}\n\n` : "";
+ let taskMarkdown = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan }) + chatCompletionInstruction(context);
+ let taskMarkdownAssignment = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
+ ...taskMarkdownInput,
+ taskPlan,
+ includeWakeComments: false,
+ }) + chatCompletionInstruction(context);
+ const taskMarkdownCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
+ ...taskMarkdownInput,
+ taskPlan,
+ includeDescription: false,
+ }) + chatCompletionInstruction(context);
+ const taskMarkdownAssignmentCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
+ ...taskMarkdownInput,
+ taskPlan,
+ includeDescription: false,
+ includeWakeComments: false,
+ }) + chatCompletionInstruction(context);
+ if (issueRef) {
+ context.paperclipIssue = {
+ id: issueRef.id,
+ identifier: issueRef.identifier,
+ title: issueRef.title,
+ description: isConversation(issueContext) ? null : issueRef.description,
+ workMode: issueRef.workMode,
+ };
+ } else {
+ delete context.paperclipIssue;
+ }
+ if (wakeCommentContext) {
+ context.paperclipWakeComment = safeWakeCommentContext;
+ } else {
+ delete context.paperclipWakeComment;
+ }
+ if (taskMarkdown) {
+ context.paperclipTaskMarkdown = taskMarkdown;
+ } else {
+ delete context.paperclipTaskMarkdown;
+ }
+ if (taskMarkdownAssignment) {
+ context.paperclipTaskMarkdownAssignment = taskMarkdownAssignment;
+ } else {
+ delete context.paperclipTaskMarkdownAssignment;
+ }
+ if (taskMarkdownCompact && taskMarkdownCompact !== taskMarkdown) {
+ context.paperclipTaskMarkdownCompact = taskMarkdownCompact;
+ } else {
+ delete context.paperclipTaskMarkdownCompact;
+ }
+ if (taskMarkdownAssignmentCompact && taskMarkdownAssignmentCompact !== taskMarkdownAssignment) {
+ context.paperclipTaskMarkdownAssignmentCompact = taskMarkdownAssignmentCompact;
+ } else {
+ delete context.paperclipTaskMarkdownAssignmentCompact;
+ }
+ context.paperclipTaskCommunicationGuidance = [context.paperclipTaskCommunicationGuidance, FAST_RESPONSE_AGENT_GUIDANCE].filter(Boolean).join("\n\n");
+ if (issueRef) {
+ const redactedWakeContext = await createRunSecretRedactionRegistry(
+ db,
+ ).redactForIssue(agent.companyId, issueRef.id, {
+ paperclipIssue: context.paperclipIssue,
+ paperclipWakeComment: context.paperclipWakeComment,
+ paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance,
+ paperclipTaskMarkdown: context.paperclipTaskMarkdown,
+ paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact,
+ paperclipTaskMarkdownAssignment: context.paperclipTaskMarkdownAssignment,
+ paperclipTaskMarkdownAssignmentCompact: context.paperclipTaskMarkdownAssignmentCompact,
+ });
+ context.paperclipIssue = redactedWakeContext.paperclipIssue;
+ context.paperclipTaskCommunicationGuidance = redactedWakeContext.paperclipTaskCommunicationGuidance;
+ if (redactedWakeContext.paperclipWakeComment) {
+ context.paperclipWakeComment =
+ redactedWakeContext.paperclipWakeComment;
+ }
+ if (redactedWakeContext.paperclipTaskMarkdown) {
+ context.paperclipTaskMarkdown =
+ redactedWakeContext.paperclipTaskMarkdown;
+ }
+ if (redactedWakeContext.paperclipTaskMarkdownCompact) {
+ context.paperclipTaskMarkdownCompact =
+ redactedWakeContext.paperclipTaskMarkdownCompact;
+ }
+ if (redactedWakeContext.paperclipTaskMarkdownAssignment) {
+ context.paperclipTaskMarkdownAssignment =
+ redactedWakeContext.paperclipTaskMarkdownAssignment;
+ }
+ if (redactedWakeContext.paperclipTaskMarkdownAssignmentCompact) {
+ context.paperclipTaskMarkdownAssignmentCompact =
+ redactedWakeContext.paperclipTaskMarkdownAssignmentCompact;
+ }
+ }
+ if (issueRef) {
+ const digest = (value: string | null | undefined) =>
+ value?.trim()
+ ? createHash("sha256")
+ .update(value.trim().replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""))
+ .digest("hex")
+ : null;
+ const redactedIssue = parseObject(context.paperclipIssue);
+ context.paperclipTurnContext = {
+ version: 1,
+ assignment: {
+ owner: "task_markdown",
+ description: {
+ id: issueRef.id,
+ revision: digest(readNonEmptyString(redactedIssue.description)),
+ },
+ },
+ events: {
+ owner: "wake_prompt",
+ comments: safeWakeComments.map((comment) => ({
+ id: comment.id,
+ revision: digest(comment.body),
+ })),
+ },
+ } satisfies PaperclipTurnContext;
+ } else {
+ delete context.paperclipTurnContext;
+ }
+ return { continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext };
+}
diff --git a/tests/e2e/workspace-bootstrap/README.md b/tests/e2e/workspace-bootstrap/README.md
index 3bf25b641e7..2d16539cf5e 100644
--- a/tests/e2e/workspace-bootstrap/README.md
+++ b/tests/e2e/workspace-bootstrap/README.md
@@ -31,5 +31,7 @@ pnpm exec playwright test --config tests/e2e/workspace-bootstrap/playwright.conf
The tests prove that one timeout schedules a retry and completes without manual
Retry, that persistent timeouts stop after three total runs, and that the final
state survives reload. Source edits remain intact and ignored private files are
-not copied. Screenshots and failure traces go to `test-results/workspace-bootstrap`.
+not copied. The worker also verifies that persisted task context contains the
+assignment, the full and compact prompt variants, and the description revision.
+Screenshots and failure traces go to `test-results/workspace-bootstrap`.
Stop test-drive with Ctrl-C. Its disposable data directory remains for inspection.
diff --git a/tests/e2e/workspace-bootstrap/worker.mjs b/tests/e2e/workspace-bootstrap/worker.mjs
index 07222bfb2bc..b94d0c150c6 100644
--- a/tests/e2e/workspace-bootstrap/worker.mjs
+++ b/tests/e2e/workspace-bootstrap/worker.mjs
@@ -3,6 +3,7 @@
import { readFile, readdir, writeFile, access } from "node:fs/promises";
import path from "node:path";
import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
const env = process.env;
const headers = { Authorization: `Bearer ${env.PAPERCLIP_API_KEY}`, "X-Paperclip-Run-Id": env.PAPERCLIP_RUN_ID, "Content-Type": "application/json" };
@@ -14,6 +15,17 @@ const api = async (route, method = "GET", body) => {
const run = await api(`/heartbeat-runs/${env.PAPERCLIP_RUN_ID}`);
assert.equal(run.contextSnapshot.paperclipEnvironment.driver, "local");
const issueId = run.contextSnapshot.issueId;
+const issue = await api(`/issues/${issueId}`);
+assert.equal(run.contextSnapshot.paperclipIssue.id, issue.id);
+assert.equal(run.contextSnapshot.paperclipIssue.description, issue.description);
+// The fixture's title equals its description; compact prompts keep the title.
+const descriptionSection = `\nIssue description:\n\`\`\`text\n${issue.description}\n\`\`\``;
+assert(run.contextSnapshot.paperclipTaskMarkdown.includes(descriptionSection));
+assert(run.contextSnapshot.paperclipTaskMarkdownAssignment.includes(descriptionSection));
+assert(!run.contextSnapshot.paperclipTaskMarkdownCompact.includes(descriptionSection));
+assert(!run.contextSnapshot.paperclipTaskMarkdownAssignmentCompact.includes(descriptionSection));
+assert.equal(run.contextSnapshot.paperclipTurnContext.assignment.description.revision,
+ createHash("sha256").update(issue.description.trim()).digest("hex"));
const repositories = await readdir(path.join(process.cwd(), ".paperclip-repositories"));
const repo = path.join(process.cwd(), ".paperclip-repositories", repositories.find((name) => !name.includes(".clone-")));
assert.equal(await readFile(path.join(repo, "README.md"), "utf8"), "Existing uncommitted work\n");
From fb21a3f30ac16f8dd281299f29b077285cb5f130 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 08:21:46 -0500
Subject: [PATCH 04/23] refactor(chat): extract provider message normalization
(#15823)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip manages AI agents and keeps their work attached to company
tasks.
> - Chat providers deliver messages and corrections into those tasks.
> - The chat channel service has grown to 39,155 lines.
> - Provider message normalization has no database or worker ownership
of its own.
> - This pull request moves that responsibility into one module while
preserving the service body.
> - Reviewers can now find provider identities, timestamps, and edit
revisions together.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Maintenance of inbound chat message normalization in the server.
**Subsystem affected**
Server chat channels.
**Current behavior**
Provider message parsers and identity rules share a 39,155-line file
with credentials, runtime ownership, database admission, and publication
workers.
**Proposed behavior**
Move provider message normalization into
`chat-channels/provider-messages.ts`. Keep service exports and runtime
behavior unchanged.
**Reason and benefit**
This creates a clear first boundary for an incremental refactor. A dated
plan ranks the next four larger extractions. No duplicate chat refactor
was found in the GitHub title search.
**Breaking changes**
None intended. No schema, endpoint, transport, or public service API
changes.
Stack order: #15823 (provider messages) → #15834 (resource
reconciliation) → #15836 (identity links) → #15844 (publication
replacement) → #15845 (failed-run retries). Merge in this order;
retarget each child after its predecessor lands.
## What Changed
- Move provider message parsing into
`chat-channels/telegram/messages.ts`, `slack/messages.ts`,
`discord/messages.ts`, `microsoft-teams/messages.ts` and
`github/messages.ts`.
- Keep shared actor normalization, actor data and the inbound text limit
in `chat-channels/message-lifecycle.ts`. These six modules total 566
lines / 19,534 bytes.
- Import provider modules directly. Follow this provider-folder
convention for future extractions.
- Preserve exact actor IDs, edit hashes, timestamp provenance, and
Telegram message identity rules.
- Narrow two input types to the Teams parser method and the retained
delivery fields actually read.
- Reduce `chat-channels.ts` from 39,155 lines / 1,509,090 bytes to
38,643 lines / 1,491,703 bytes.
- Record five ranked extraction candidates and verification evidence in
the dated plan.
## Verification
- The original extraction passed complete CI and 5/5 review at
`cbba30a526`. Those results are historical; the updated head is
`90ac517e6c310c8cf194dbbd668006f740e58f3a`.
- Structural comparison: all 21 moved declarations match the base after
normalizing exports and two type-only changes. All remaining non-import
declarations, including the complete service body, match the base.
- Provider-folder update: the same 48 public service integration cases
passed locally before and after the move on PR #15823. They cover
Slack/Discord revision identity, Teams edits, GitHub edits, Telegram
rich messages and provider chronology.
- All 22 message declarations retain identical bodies; the service
changes imports only. The four dependent extractions rebased without
conflicts.
- Final-stack workspace `pnpm -r typecheck` passed. Module-boundary and
whitespace checks passed.
- Final-stack `pnpm build` passed. The fresh `pnpm test:run` was
interrupted after PostgreSQL startup failures recurred. An isolated
`native-status-arbiter-corpus.test.ts` run failed during database
initialization after five attempts; all 45 tests skipped. All 32 host
shared-memory slots were occupied. The full local gate remains
unverified; only the task-owned run was stopped.
- Greptile rated updated head `90ac517e6c310c8cf194dbbd668006f740e58f3a`
5/5 with no inline review threads.
- Updated-head CI passed: 53 successful checks and two skipped checks,
including all chat integration shards, typecheck, build and browser
suites.
- Tests use disposable databases and fixture provider transports. No
live third-party execution is claimed.
## Risks
- A wrong module import or changed revision identity could affect
message admission. Existing service-path tests and the declaration
comparison cover those risks.
- Authentication, company-scoped transactions, leases, background
workers, and shutdown remain in the original service.
- The service closure remains large. Resource reconciliation is the
recommended next extraction.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning, local code execution, and
repository tools. The exact runtime model ID and context-window size are
not exposed in this session. No separate planning or review agent was
used.
## 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
- [ ] 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
---
.../2026-10-10-chat-channels-refactor.md | 127 ++++
server/src/services/chat-channels.ts | 574 +-----------------
.../chat-channels/discord/messages.ts | 30 +
.../services/chat-channels/github/messages.ts | 132 ++++
.../chat-channels/message-lifecycle.ts | 39 ++
.../chat-channels/microsoft-teams/messages.ts | 99 +++
.../services/chat-channels/slack/messages.ts | 43 ++
.../chat-channels/telegram/messages.ts | 223 +++++++
8 files changed, 724 insertions(+), 543 deletions(-)
create mode 100644 doc/plans/2026-10-10-chat-channels-refactor.md
create mode 100644 server/src/services/chat-channels/discord/messages.ts
create mode 100644 server/src/services/chat-channels/github/messages.ts
create mode 100644 server/src/services/chat-channels/message-lifecycle.ts
create mode 100644 server/src/services/chat-channels/microsoft-teams/messages.ts
create mode 100644 server/src/services/chat-channels/slack/messages.ts
create mode 100644 server/src/services/chat-channels/telegram/messages.ts
diff --git a/doc/plans/2026-10-10-chat-channels-refactor.md b/doc/plans/2026-10-10-chat-channels-refactor.md
new file mode 100644
index 00000000000..711c6e6b125
--- /dev/null
+++ b/doc/plans/2026-10-10-chat-channels-refactor.md
@@ -0,0 +1,127 @@
+# Chat channel service extraction sequence
+
+## Scope and finish line
+
+Apply the refactor-large-file workflow to `server/src/services/chat-channels.ts`.
+Map five responsibilities, then extract and verify one at a time. This slice
+extracts provider message normalization without changing provider behavior,
+public service exports, database contracts, authentication, or worker ownership.
+Subsequent slices require reassessment against the preceding result. No merge or
+deployment is part of this slice.
+
+## Baseline
+
+- Base: `1100682261` on a clean, detached task worktree.
+- File: 39,132 lines / 1,502,701 bytes; imports occupy 421 lines.
+- `chatChannelService`: 36,152 lines, including nested functions and wiring.
+ This is not a measurement of orchestration alone. The first slice removes
+ top-level normalization; it does not simplify that closure yet.
+- Largest nested functions: `processMessage` (2,164 lines), `handleAction`
+ (1,074), `applyProviderLifecycleEffect` (895), `configureWithCredentialLease`
+ (868), `processSelectedPublication` (728), `handleModalSubmit` (708),
+ `handleWebhook` (684), `failedChatRetrySource` (677), and
+ `processLifecycleDelivery` (657).
+- Existing siblings already own SDK runtimes/state, provider inventory reads,
+ resource lifecycle parsing, GitHub app management, registration, publication
+ projection/streaming, Teams transfers, Telegram media, and interaction forms.
+ Extend those boundaries where appropriate instead of duplicating them.
+- Callers include application assembly, chat routes, issue/agent routes, and
+ existing unit/integration tests. Keep their imports and service API stable.
+
+## Ranked extraction candidates
+
+Estimates are source lines moved / net reduction after wiring. Later estimates
+are provisional: explicit dependency contracts must precede each extraction.
+New paths below are relative to `server/src/services/`.
+
+| Rank / responsibility | Destination | Approx. move / net | Inputs and outputs | State and effects owned | Risk and verification |
+| --- | --- | --- | --- | --- | --- |
+| 1. Provider message normalization | `chat-channels/provider-messages.ts` | 540 / 520 | Provider payload, author, Request, Teams parser, or retained delivery → actor, lifecycle event, message ID, revision digest, timestamp | No mutable service state or DB writes; consumes the supplied GitHub Request body | Preserve exact revision hashes, limits, native IDs, malformed-input handling, and raw clock provenance. Existing public service integration tests cover all four providers. |
+| 2. Resource inventory reconciliation and availability | `chat-channels/resource-reconciliation.ts`, alongside `chat-provider-inventory.ts` and `chat-provider-lifecycle.ts` | 1,300–1,500 / 1,200–1,400 | Endpoint, inventory/lifecycle effect, credential lease, DB and explicit runtime callbacks → reconciled resources and availability | Company-scoped resource/conversation transactions; GitHub stable repository identity and provider-order checks | Keep advisory locks, lease checks and the complete availability transaction together. Verify repository rename, removal/reinstall, resource auditing, Telegram migration and stale callbacks. |
+| 3. Identity linking | `chat-channels/identity-links.ts` | 430–600 / 390–550 | DB, endpoint lookup, live public URL getter, principal, token, authenticated user → link preview/confirmation/revocation | Token hashes/expiry, membership checks, join requests and transactional audit records | No cached authority or URL snapshot. Preserve endpoint/company binding, replay behavior and revoked/conflicting links; run identity route and DB integration tests. |
+| 4. Publication replacement and supersession | `chat-channels/publication-replacement.ts` (the existing reconciliation module owns wake signals, a separate responsibility) | 1,000–1,150 / 950–1,100 | DB, publication, current endpoint/conversation/run → replacement candidate or suppression reason | Scoped reads and existing issue/run authorization snapshots; no transport or queue ownership | Preserve exact outbound link ownership, interaction precedence, terminal failure replacement, and issue→run lock order. Run publication reconciliation, progress and integration fixtures. |
+| 5. Failed chat run retry coordination | `chat-channels/failed-run-retry.ts` | 1,600–1,900 / 1,400–1,700 | DB, source/action IDs, explicit admission/heartbeat/publication hooks → authorized retry, claim and completion | Per-service in-flight retry promises and authority registration/cleanup, persisted source evidence | Higher risk: preserve single retry ownership, no replay of provider calls, live source revalidation and shutdown ordering. Run reconstructed-service, edited-source, revocation and concurrent retry integration fixtures. |
+
+Start with candidate 1: it establishes a domain folder and removes an independent
+responsibility without introducing a mutable context object. It is deliberately
+smaller than the later stateful slices. Resource lifecycle effects remain in the
+existing parser; message edits/deletes are a different normalization contract.
+
+Do not move runtime leases, webhook authentication, conversation admission,
+delivery transactions, transport attempts, or shutdown piecemeal. The service
+must continue to show who admits work and who releases resources. Candidate 2
+needs a narrow callback contract; candidate 5 must preserve the same service
+lifetime for registration and in-flight promises. Candidates are sequential,
+not independent concurrent changes to this file.
+
+## Provider folder convention (2026-10-11)
+
+Provider-specific code belongs under `chat-channels//`, using the
+existing provider identifiers. The normalization extraction now lives in
+`telegram/messages.ts`, `slack/messages.ts`, `discord/messages.ts`,
+`microsoft-teams/messages.ts` and `github/messages.ts`. Shared actor normalization,
+actor data and the inbound text limit live in `message-lifecycle.ts`. The service
+imports the provider modules directly. Future extractions should follow this
+layout; move older flat provider modules when their responsibility is touched.
+
+All 22 declarations retain their exact bodies. The service changes imports only.
+The same 48 provider integration cases passed before and after this move, and
+server typecheck and module boundaries pass. The historical extraction sizes
+below predate this folder change: direct imports add 10 service lines / 281 bytes.
+The six message modules now total 566 lines / 19,534 bytes, including imports.
+
+## Verification and progress
+
+Before the production move, run existing service integration cases for message
+lifecycle, file revision identity, provider timestamps, Telegram zero-message
+handling and rich content. These use disposable PostgreSQL and fixture provider
+transports, not live third-party execution. Keep those tests at their existing
+public entry points. Add characterization only if inspection exposes a gap.
+
+After the move, repeat that selection, compare extracted declarations and the
+remaining file mechanically against the base, then run the relevant adjacent
+suites and repository typecheck, full test and build gates. Record blockers and
+unverified checks explicitly; do not treat skipped integration tests as passes.
+
+- Dependencies: installed with the repository-pinned pnpm 9.15.4. The host's
+ newer pnpm ignored legacy patch settings, so verification uses the pinned tool.
+- Original-base integration selection: 48 passed before and 48 passed after
+ extraction. Existing tests cover the boundary; no new test-only exports or
+ mock-only characterization were needed.
+- Rebased onto `b1ebe3de3c` after master added fast-response acknowledgements
+ and action queue notifications. The only conflict was the import block;
+ retain upstream imports and add the normalization import. All 21 extracted
+ declarations match the new base, ignoring added exports and two narrowed
+ input types. Every remaining non-import declaration, including the entire
+ service body, matches the new base exactly.
+- Current size: 39,155 lines / 1,509,090 bytes before extraction; 38,633 lines /
+ 1,491,422 bytes after. Net reduction: 522 lines / 17,668 bytes. Destination:
+ 552 lines / 19,028 bytes, including imports and the shared text-limit constant.
+- On the rebased source, the service spans 36,175 lines: 287 direct nested
+ function declarations occupy 35,324 lines; 51 other statements occupy 504
+ lines of state, wiring, registration callbacks and the public API. The rest
+ is comments, whitespace and delimiters. Those counts remain unchanged by
+ this slice; they make the remaining orchestration work explicit.
+- The module takes only the Teams parser method and the two delivery fields it
+ reads, rather than requiring an entire runtime or database row. These are
+ type-only changes. It imports sibling implementations directly, never the
+ service entry point. Public service exports remain unchanged.
+- Initial full-test/typecheck runs were interrupted to rebase and are not
+ passes. Rebased integration selection: 48 passed (the other 1,185 cases were
+ excluded by the test-name filter). Module-boundary and whitespace checks pass.
+ Workspace `pnpm -r typecheck` and `pnpm build`: passed. The full suite and
+ remote checks were still running at PR opening; final results belong in the
+ PR's Verification section below.
+
+Reproduce the focused service-path check with the repository-pinned pnpm:
+
+```sh
+pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts \
+ -t 'records message edits and deletes durably|redacts lifecycle edits|Telegram edit|Teams edit, soft-delete|verified Telegram edited_message|verified GitHub comment edit|provider timestamp provenance|Telegram retained zero-message admission|Telegram current rich inbound compatibility|deduplicates Slack file-only revisions|preserves exact Discord source authority'
+```
+
+Branch: `codex/chat-channel-provider-messages`. The user authorized publishing
+this slice to `paperclipai/paperclip`; subsequent extractions are not part of
+this PR.
+
+Review and final verification: [PR #15823](https://github.com/paperclipai/paperclip/pull/15823).
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index 12bff8621d9..9716073c91f 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -1,3 +1,34 @@
+import {
+ MAX_INBOUND_TEXT,
+ stableExternalPrincipalId,
+ type LifecycleActor,
+ lifecycleActorFromAuthor,
+} from "./chat-channels/message-lifecycle.js";
+import {
+ telegramLifecycleEventFromPayload,
+ isTelegramEditedMessageRaw,
+ isTelegramMigrationRaw,
+ telegramMessageSequence,
+ telegramMessageId,
+ telegramZeroMessageId,
+ telegramDeliveryHasZeroMessageId,
+ telegramMessageSentAt,
+} from "./chat-channels/telegram/messages.js";
+import {
+ slackLifecycleFilesDigest,
+ slackMessageSentAt,
+} from "./chat-channels/slack/messages.js";
+import {
+ discordLifecycleFilesDigest,
+} from "./chat-channels/discord/messages.js";
+import {
+ microsoftTeamsLifecycleEventFromPayload,
+ teamsActivitySentAt,
+} from "./chat-channels/microsoft-teams/messages.js";
+import {
+ type GitHubLifecycleEvent,
+ githubLifecycleEventFromRequest,
+} from "./chat-channels/github/messages.js";
import { dueChatAction, nextChatActionAt, SLACK_COMMAND_POST_STALE_MS, SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS, SLACK_COMMAND_ADMISSION_STALE_MS, PROVIDER_EFFECT_STALE_MS } from "./chat-action-work.js";
import type { ScheduledChatAction } from "./chat-work-notifications.js";
import { notifyChatDeliveryWork, notifyChatPublicationWork, notifyChatEndpointWork, notifyChatActionWork, notifyChatVerificationWork } from "./chat-work-notifications.js";
@@ -381,7 +412,6 @@ import {
} from "./chat-inbound-wakeup-publications.js";
import {
normalizeMicrosoftTeamsCredentialIds,
- normalizeMicrosoftTeamsExternalPrincipalId,
} from "./chat-teams-credentials.js";
import {
isTeamsInlineImageContentType,
@@ -847,7 +877,6 @@ const SUPPLIED_CREDENTIAL_KEYS: Record = {
telegram: ["botToken"],
};
-const MAX_INBOUND_TEXT = 100_000;
const MAX_ERROR_TEXT = 2_000;
const DELIVERY_PROCESSING_STALE_MS = 60_000;
// Reactions can beat the transaction that persists a just-sent provider
@@ -1737,19 +1766,6 @@ function telegramGuidanceCommand(
return "unknown";
}
-function stableExternalPrincipalId(
- provider: ChatProvider,
- author: Author,
- raw?: unknown,
-): string {
- if (provider !== "microsoft-teams" || !raw || typeof raw !== "object")
- return author.userId;
- const from = (raw as { from?: unknown }).from;
- if (!from || typeof from !== "object") return author.userId;
- const aadObjectId = (from as { aadObjectId?: unknown }).aadObjectId;
- return normalizeMicrosoftTeamsExternalPrincipalId(aadObjectId, author.userId);
-}
-
function safeTitle(text: string, fallback: string): string {
const line = text
.replace(/<@[A-Z0-9]+>/gi, "")
@@ -2037,534 +2053,6 @@ function isSupportedTelegramWebhookBaseUrl(value: string | null): boolean {
}
}
-type GitHubLifecycleEvent = {
- actor?: LifecycleActor;
- isBotMessage?: true;
- eventKind: "message_updated" | "message_deleted";
- messageId: string;
- providerEventId?: string;
- providerMessageSequence: number | null;
- providerSentAt: string | null;
- revision: string;
- text: string;
- threadId: string;
-};
-
-type TelegramLifecycleEvent = {
- actor: LifecycleActor;
- eventKind: "message_updated";
- messageId: string;
- providerEventId?: string;
- providerMessageSequence: number;
- providerSentAt: string;
- providerUpdateId: number | null;
- revision: string;
- text: string;
- threadId: string;
-};
-
-type LifecycleActor = {
- displayName: string;
- externalId: string;
- handle: string;
-};
-
-type MicrosoftTeamsLifecycleEvent = {
- actor?: LifecycleActor;
- eventKind: "message_updated" | "message_deleted" | "message_restored";
- messageId: string;
- providerSentAt: string | null;
- raw?: unknown;
- revision: string;
- text: string;
- threadId: string;
-};
-
-function lifecycleActorFromAuthor(
- provider: ChatProvider,
- author: Author,
- raw?: unknown,
-): LifecycleActor {
- return {
- externalId: stableExternalPrincipalId(provider, author, raw),
- displayName: author.fullName,
- handle: author.userName,
- };
-}
-
-function slackLifecycleFilesDigest(raw: unknown): string {
- const files =
- raw && typeof raw === "object" && !Array.isArray(raw)
- ? (raw as Record).files
- : undefined;
- // Match the pinned Slack adapter's content-change projection. Transport
- // locators/unfurls are not content identity and must never enter durable keys.
- const projection = Array.isArray(files)
- ? files.map((file) => {
- const row =
- file && typeof file === "object" && !Array.isArray(file)
- ? (file as Record)
- : {};
- return [
- ...["id", "name", "mimetype"].map((key) =>
- typeof row[key] === "string" ? row[key] : null,
- ),
- ...["size", "original_w", "original_h"].map((key) =>
- typeof row[key] === "number" && Number.isFinite(row[key])
- ? row[key]
- : null,
- ),
- ];
- })
- : [];
- return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
-}
-
-function discordLifecycleFilesDigest(raw: unknown): string {
- const attachments =
- raw && typeof raw === "object" && !Array.isArray(raw)
- ? (raw as Record).attachments
- : undefined;
- // Match the pinned Gateway adapter's flattened, ordered file projection.
- // Rotating CDN URLs are transport authority, never source revision identity.
- const projection = Array.isArray(attachments)
- ? attachments.map((attachment) => {
- const row =
- attachment &&
- typeof attachment === "object" &&
- !Array.isArray(attachment)
- ? (attachment as Record)
- : {};
- return [
- ...["id", "filename", "content_type"].map((key) =>
- typeof row[key] === "string" ? row[key] : null,
- ),
- typeof row.size === "number" && Number.isFinite(row.size)
- ? row.size
- : null,
- ];
- })
- : [];
- return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
-}
-
-function telegramLifecycleActor(message: {
- from?: unknown;
- sender_chat?: unknown;
-}): LifecycleActor | null {
- const candidate =
- message.from &&
- typeof message.from === "object" &&
- !Array.isArray(message.from)
- ? (message.from as Record)
- : message.sender_chat &&
- typeof message.sender_chat === "object" &&
- !Array.isArray(message.sender_chat)
- ? (message.sender_chat as Record)
- : null;
- if (!candidate) return null;
- const id = candidate.id;
- if (typeof id !== "string" && typeof id !== "number") return null;
- const handle =
- typeof candidate.username === "string" ? candidate.username : "";
- const displayName =
- [candidate.first_name, candidate.last_name]
- .filter(
- (value): value is string => typeof value === "string" && Boolean(value),
- )
- .join(" ") ||
- (typeof candidate.title === "string" ? candidate.title : "") ||
- handle ||
- String(id);
- return { externalId: String(id), displayName, handle };
-}
-
-function microsoftTeamsLifecycleEventFromPayload(
- payload: unknown,
- endpointRuntime: ChatEndpointRuntime,
-): MicrosoftTeamsLifecycleEvent | null {
- if (!payload || typeof payload !== "object" || Array.isArray(payload))
- return null;
- const activity = payload as {
- channelData?: { eventType?: unknown };
- timestamp?: unknown;
- type?: unknown;
- };
- const eventType = activity.channelData?.eventType;
- const isEdit =
- activity.type === "messageUpdate" && eventType === "editMessage";
- const isRestore =
- activity.type === "messageUpdate" && eventType === "undeleteMessage";
- const isDelete =
- activity.type === "messageDelete" && eventType === "softDeleteMessage";
- if (!isEdit && !isRestore && !isDelete) return null;
- const message = endpointRuntime.parseMicrosoftTeamsMessage(payload);
- if (!message?.id || !message.threadId) return null;
- const body = message.text.slice(0, MAX_INBOUND_TEXT);
- const providerSentAt =
- typeof activity.timestamp === "string" &&
- Number.isFinite(Date.parse(activity.timestamp))
- ? new Date(activity.timestamp).toISOString()
- : null;
- const bodyHash = createHash("sha256").update(body).digest("hex");
- const revision = providerSentAt ? `${providerSentAt}:${bodyHash}` : bodyHash;
- return {
- ...(!isDelete
- ? {
- actor: lifecycleActorFromAuthor(
- "microsoft-teams",
- message.author,
- message.raw,
- ),
- }
- : {}),
- eventKind: isDelete
- ? "message_deleted"
- : isRestore
- ? "message_restored"
- : "message_updated",
- messageId: message.id,
- providerSentAt,
- raw: message.raw,
- revision,
- text: isDelete
- ? "An external message in this conversation was deleted."
- : isRestore
- ? `An external message was restored:\n\n${body}`
- : `An external message was edited:\n\n${body}`,
- threadId: message.threadId,
- };
-}
-
-function telegramLifecycleEventFromPayload(
- payload: unknown,
-): TelegramLifecycleEvent | null {
- if (!payload || typeof payload !== "object" || Array.isArray(payload))
- return null;
- const update = payload as {
- edited_message?: unknown;
- update_id?: unknown;
- };
- const edited = update.edited_message;
- if (!edited || typeof edited !== "object" || Array.isArray(edited))
- return null;
- const message = edited as {
- caption?: unknown;
- chat?: { id?: unknown };
- edit_date?: unknown;
- from?: unknown;
- message_id?: unknown;
- message_thread_id?: unknown;
- sender_chat?: unknown;
- text?: unknown;
- rich_message?: unknown;
- };
- const chatId = message.chat?.id;
- const messageId = message.message_id;
- const editDate = message.edit_date;
- if (
- (typeof chatId !== "string" && typeof chatId !== "number") ||
- typeof messageId !== "number" ||
- !Number.isSafeInteger(messageId) ||
- messageId < 0 ||
- typeof editDate !== "number" ||
- !Number.isSafeInteger(editDate) ||
- editDate <= 0
- )
- return null;
- const topicId = message.message_thread_id;
- if (topicId !== undefined && typeof topicId !== "number") return null;
- const rich = normalizeTelegramRichMessage(message);
- const body =
- rich?.text ??
- (typeof message.text === "string"
- ? message.text
- : typeof message.caption === "string"
- ? message.caption
- : "");
- const chat = String(chatId);
- const providerUpdateId =
- typeof update.update_id === "number" &&
- Number.isSafeInteger(update.update_id) &&
- update.update_id >= 0
- ? update.update_id
- : null;
- const bodyHash = createHash("sha256").update(body).digest("hex");
- const actor = telegramLifecycleActor(message);
- if (!actor) return null;
- return {
- actor,
- eventKind: "message_updated",
- messageId: `${chat}:${messageId}`,
- ...(providerUpdateId === null
- ? {}
- : { providerEventId: `telegram:update:${providerUpdateId}` }),
- providerMessageSequence: messageId,
- providerSentAt: new Date(editDate * 1_000).toISOString(),
- providerUpdateId,
- // Bot API timestamps have one-second resolution. The update id is the
- // authoritative identity when present; retaining a content hash keeps the
- // fallback path from collapsing two distinct edits in that same second.
- revision: `${editDate}:${bodyHash}${rich ? `:${rich.mediaDigest}` : ""}`,
- text: `An external message was edited:\n\n${body.slice(0, MAX_INBOUND_TEXT)}`,
- threadId:
- topicId === undefined
- ? `telegram:${chat}`
- : `telegram:${chat}:${topicId}`,
- };
-}
-
-function isTelegramEditedMessageRaw(raw: unknown): boolean {
- return (
- !!raw &&
- typeof raw === "object" &&
- !Array.isArray(raw) &&
- typeof (raw as { edit_date?: unknown }).edit_date === "number"
- );
-}
-
-function isTelegramMigrationRaw(raw: unknown): boolean {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
- const value = raw as {
- migrate_from_chat_id?: unknown;
- migrate_to_chat_id?: unknown;
- };
- return (
- value.migrate_from_chat_id !== undefined ||
- value.migrate_to_chat_id !== undefined
- );
-}
-
-function telegramMessageSequence(raw: unknown): number | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const value = (raw as { message_id?: unknown }).message_id;
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0
- ? value
- : null;
-}
-
-function telegramMessageId(raw: unknown): string | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const value = raw as {
- chat?: { id?: unknown };
- message_id?: unknown;
- };
- const messageId = value.message_id;
- const chatId = value.chat?.id;
- if (
- typeof messageId !== "number" ||
- !Number.isSafeInteger(messageId) ||
- messageId <= 0
- )
- return null;
- const normalizedChatId =
- typeof chatId === "number" && Number.isSafeInteger(chatId)
- ? String(chatId)
- : typeof chatId === "string" && /^-?\d+$/.test(chatId)
- ? chatId
- : null;
- return normalizedChatId ? `${normalizedChatId}:${messageId}` : null;
-}
-
-function telegramZeroMessageId(value: unknown): boolean {
- // The pinned adapter qualifies native message IDs with the numeric chat ID.
- // Keep legacy synthetic/non-numeric IDs compatible; zero is specifically
- // the provider's non-ordinary message identity, never an admitted source.
- return typeof value === "string" && /^(?:-?\d+:)?0$/.test(value);
-}
-
-function telegramDeliveryHasZeroMessageId(
- delivery: DeliveryRow,
- provider: ChatProvider,
-): boolean {
- if (provider !== "telegram") return false;
- const normalized = delivery.normalizedEvent as {
- message?: {
- providerMessageId?: unknown;
- providerMessageSequence?: unknown;
- };
- conversation?: { externalThreadId?: unknown };
- };
- if (
- telegramZeroMessageId(normalized.message?.providerMessageId) ||
- normalized.message?.providerMessageSequence === 0
- )
- return true;
- const threadId = normalized.conversation?.externalThreadId;
- // Check the independently retained event key only inside its exact thread
- // namespace, not arbitrary IDs ending in ':0' from synthetic commands.
- return (
- typeof threadId === "string" &&
- delivery.providerEventId.startsWith(`${threadId}:`) &&
- telegramZeroMessageId(delivery.providerEventId.slice(threadId.length + 1))
- );
-}
-
-function telegramMessageSentAt(raw: unknown): Date | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const value = (raw as { date?: unknown }).date;
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
- return null;
- const sentAt = new Date(value * 1_000);
- return Number.isFinite(sentAt.getTime()) ? sentAt : null;
-}
-
-function slackMessageSentAt(raw: unknown, messageId: string): Date | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const ts = (raw as { ts?: unknown }).ts;
- if (
- ts !== messageId ||
- typeof ts !== "string" ||
- !/^[1-9][0-9]{0,12}\.[0-9]{6}$/.test(ts)
- )
- return null;
- const date = new Date(Number(ts) * 1_000);
- return Number.isFinite(date.getTime()) ? date : null;
-}
-
-function teamsActivitySentAt(raw: unknown): Date | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const value = (raw as { timestamp?: unknown }).timestamp;
- // The SDK may supply a Date, but its missing-timestamp display fallback is
- // only on Message.metadata. Never use that fallback as provider chronology.
- if (value instanceof Date)
- return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : null;
- if (
- typeof value !== "string" ||
- value.length > 64 ||
- !/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,7})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(
- value,
- )
- )
- return null;
- // Date.parse otherwise normalizes nonexistent dates such as February 30.
- const day = value.slice(0, 10);
- const calendar = new Date(`${day}T00:00:00.000Z`);
- if (
- !Number.isFinite(calendar.getTime()) ||
- calendar.toISOString().slice(0, 10) !== day
- )
- return null;
- const sentAt = new Date(value);
- return Number.isFinite(sentAt.getTime()) ? sentAt : null;
-}
-
-async function githubLifecycleEventFromRequest(
- request: Request,
-): Promise {
- const eventType = request.headers.get("x-github-event");
- const deliveryId = request.headers.get("x-github-delivery")?.trim() || null;
- if (
- eventType !== "issue_comment" &&
- eventType !== "pull_request_review_comment"
- )
- return null;
- const payload = (await request.json()) as {
- action?: unknown;
- comment?: {
- id?: unknown;
- in_reply_to_id?: unknown;
- body?: unknown;
- updated_at?: unknown;
- user?: { type?: unknown };
- };
- issue?: { number?: unknown; pull_request?: unknown };
- pull_request?: { number?: unknown };
- repository?: { name?: unknown; owner?: { login?: unknown } };
- sender?: { id?: unknown; login?: unknown; type?: unknown };
- };
- if (payload.action !== "edited" && payload.action !== "deleted") return null;
- const owner = payload.repository?.owner?.login;
- const repo = payload.repository?.name;
- const messageId = payload.comment?.id;
- if (
- typeof owner !== "string" ||
- typeof repo !== "string" ||
- (typeof messageId !== "string" && typeof messageId !== "number")
- )
- return null;
- let threadId: string;
- if (eventType === "issue_comment") {
- const number = payload.issue?.number;
- if (typeof number !== "number") return null;
- threadId = payload.issue?.pull_request
- ? `github:${owner}/${repo}:${number}`
- : `github:${owner}/${repo}:issue:${number}`;
- } else {
- const number = payload.pull_request?.number;
- const rootCommentId =
- payload.comment?.in_reply_to_id ?? payload.comment?.id;
- if (
- typeof number !== "number" ||
- (typeof rootCommentId !== "string" && typeof rootCommentId !== "number")
- )
- return null;
- threadId = `github:${owner}/${repo}:${number}:rc:${rootCommentId}`;
- }
- const eventKind =
- payload.action === "edited" ? "message_updated" : "message_deleted";
- const body =
- typeof payload.comment?.body === "string"
- ? payload.comment.body.slice(0, MAX_INBOUND_TEXT)
- : "";
- const providerRevision =
- typeof payload.comment?.updated_at === "string"
- ? payload.comment.updated_at
- : payload.action;
- const parsedProviderSentAt = new Date(providerRevision);
- const providerSentAt = Number.isFinite(parsedProviderSentAt.getTime())
- ? parsedProviderSentAt.toISOString()
- : null;
- const numericMessageId =
- typeof messageId === "number"
- ? messageId
- : typeof messageId === "string" && /^\d+$/.test(messageId)
- ? Number(messageId)
- : null;
- const providerMessageSequence =
- numericMessageId !== null &&
- Number.isSafeInteger(numericMessageId) &&
- numericMessageId >= 0
- ? numericMessageId
- : null;
- const senderId = payload.sender?.id;
- const senderLogin = payload.sender?.login;
- const actor =
- (typeof senderId === "string" || typeof senderId === "number") &&
- typeof senderLogin === "string" &&
- senderLogin.length > 0
- ? {
- externalId: String(senderId),
- displayName: senderLogin,
- handle: senderLogin,
- }
- : undefined;
- return {
- ...(actor ? { actor } : {}),
- ...(payload.comment?.user?.type === "Bot" || payload.sender?.type === "Bot"
- ? { isBotMessage: true as const }
- : {}),
- eventKind,
- messageId: String(messageId),
- ...(deliveryId ? { providerEventId: `github:delivery:${deliveryId}` } : {}),
- providerMessageSequence,
- providerSentAt,
- // GitHub's updated_at value can have coarser resolution than a quick
- // sequence of edits. Include the normalized body so distinct edits at the
- // same timestamp remain durable while an exact webhook redelivery still
- // deduplicates.
- revision:
- eventKind === "message_updated"
- ? `${providerRevision}:${createHash("sha256").update(body).digest("hex")}`
- : providerRevision,
- text:
- eventKind === "message_updated"
- ? `An external message was edited:\n\n${body}`
- : "An external message in this conversation was deleted.",
- threadId,
- };
-}
-
function githubRepositoryInventoryItemFromPayload(
payload: unknown,
): ChatProviderResourceInventoryItem | null {
diff --git a/server/src/services/chat-channels/discord/messages.ts b/server/src/services/chat-channels/discord/messages.ts
new file mode 100644
index 00000000000..f61b2a2e8ef
--- /dev/null
+++ b/server/src/services/chat-channels/discord/messages.ts
@@ -0,0 +1,30 @@
+import { createHash } from "node:crypto";
+
+
+export function discordLifecycleFilesDigest(raw: unknown): string {
+ const attachments =
+ raw && typeof raw === "object" && !Array.isArray(raw)
+ ? (raw as Record).attachments
+ : undefined;
+ // Match the pinned Gateway adapter's flattened, ordered file projection.
+ // Rotating CDN URLs are transport authority, never source revision identity.
+ const projection = Array.isArray(attachments)
+ ? attachments.map((attachment) => {
+ const row =
+ attachment &&
+ typeof attachment === "object" &&
+ !Array.isArray(attachment)
+ ? (attachment as Record)
+ : {};
+ return [
+ ...["id", "filename", "content_type"].map((key) =>
+ typeof row[key] === "string" ? row[key] : null,
+ ),
+ typeof row.size === "number" && Number.isFinite(row.size)
+ ? row.size
+ : null,
+ ];
+ })
+ : [];
+ return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
+}
diff --git a/server/src/services/chat-channels/github/messages.ts b/server/src/services/chat-channels/github/messages.ts
new file mode 100644
index 00000000000..800d806d263
--- /dev/null
+++ b/server/src/services/chat-channels/github/messages.ts
@@ -0,0 +1,132 @@
+import { createHash } from "node:crypto";
+import { MAX_INBOUND_TEXT, type LifecycleActor } from "../message-lifecycle.js";
+
+
+export type GitHubLifecycleEvent = {
+ actor?: LifecycleActor;
+ isBotMessage?: true;
+ eventKind: "message_updated" | "message_deleted";
+ messageId: string;
+ providerEventId?: string;
+ providerMessageSequence: number | null;
+ providerSentAt: string | null;
+ revision: string;
+ text: string;
+ threadId: string;
+};
+
+export async function githubLifecycleEventFromRequest(
+ request: Request,
+): Promise {
+ const eventType = request.headers.get("x-github-event");
+ const deliveryId = request.headers.get("x-github-delivery")?.trim() || null;
+ if (
+ eventType !== "issue_comment" &&
+ eventType !== "pull_request_review_comment"
+ )
+ return null;
+ const payload = (await request.json()) as {
+ action?: unknown;
+ comment?: {
+ id?: unknown;
+ in_reply_to_id?: unknown;
+ body?: unknown;
+ updated_at?: unknown;
+ user?: { type?: unknown };
+ };
+ issue?: { number?: unknown; pull_request?: unknown };
+ pull_request?: { number?: unknown };
+ repository?: { name?: unknown; owner?: { login?: unknown } };
+ sender?: { id?: unknown; login?: unknown; type?: unknown };
+ };
+ if (payload.action !== "edited" && payload.action !== "deleted") return null;
+ const owner = payload.repository?.owner?.login;
+ const repo = payload.repository?.name;
+ const messageId = payload.comment?.id;
+ if (
+ typeof owner !== "string" ||
+ typeof repo !== "string" ||
+ (typeof messageId !== "string" && typeof messageId !== "number")
+ )
+ return null;
+ let threadId: string;
+ if (eventType === "issue_comment") {
+ const number = payload.issue?.number;
+ if (typeof number !== "number") return null;
+ threadId = payload.issue?.pull_request
+ ? `github:${owner}/${repo}:${number}`
+ : `github:${owner}/${repo}:issue:${number}`;
+ } else {
+ const number = payload.pull_request?.number;
+ const rootCommentId =
+ payload.comment?.in_reply_to_id ?? payload.comment?.id;
+ if (
+ typeof number !== "number" ||
+ (typeof rootCommentId !== "string" && typeof rootCommentId !== "number")
+ )
+ return null;
+ threadId = `github:${owner}/${repo}:${number}:rc:${rootCommentId}`;
+ }
+ const eventKind =
+ payload.action === "edited" ? "message_updated" : "message_deleted";
+ const body =
+ typeof payload.comment?.body === "string"
+ ? payload.comment.body.slice(0, MAX_INBOUND_TEXT)
+ : "";
+ const providerRevision =
+ typeof payload.comment?.updated_at === "string"
+ ? payload.comment.updated_at
+ : payload.action;
+ const parsedProviderSentAt = new Date(providerRevision);
+ const providerSentAt = Number.isFinite(parsedProviderSentAt.getTime())
+ ? parsedProviderSentAt.toISOString()
+ : null;
+ const numericMessageId =
+ typeof messageId === "number"
+ ? messageId
+ : typeof messageId === "string" && /^\d+$/.test(messageId)
+ ? Number(messageId)
+ : null;
+ const providerMessageSequence =
+ numericMessageId !== null &&
+ Number.isSafeInteger(numericMessageId) &&
+ numericMessageId >= 0
+ ? numericMessageId
+ : null;
+ const senderId = payload.sender?.id;
+ const senderLogin = payload.sender?.login;
+ const actor =
+ (typeof senderId === "string" || typeof senderId === "number") &&
+ typeof senderLogin === "string" &&
+ senderLogin.length > 0
+ ? {
+ externalId: String(senderId),
+ displayName: senderLogin,
+ handle: senderLogin,
+ }
+ : undefined;
+ return {
+ ...(actor ? { actor } : {}),
+ ...(payload.comment?.user?.type === "Bot" || payload.sender?.type === "Bot"
+ ? { isBotMessage: true as const }
+ : {}),
+ eventKind,
+ messageId: String(messageId),
+ ...(deliveryId ? { providerEventId: `github:delivery:${deliveryId}` } : {}),
+ providerMessageSequence,
+ providerSentAt,
+ // GitHub's updated_at value can have coarser resolution than a quick
+ // sequence of edits. Include the normalized body so distinct edits at the
+ // same timestamp remain durable while an exact webhook redelivery still
+ // deduplicates.
+ revision:
+ eventKind === "message_updated"
+ ? `${providerRevision}:${createHash("sha256").update(body).digest("hex")}`
+ : providerRevision,
+ text:
+ eventKind === "message_updated"
+ ? `An external message was edited:\n\n${body}`
+ : "An external message in this conversation was deleted.",
+ threadId,
+ };
+}
diff --git a/server/src/services/chat-channels/message-lifecycle.ts b/server/src/services/chat-channels/message-lifecycle.ts
new file mode 100644
index 00000000000..00084d9169c
--- /dev/null
+++ b/server/src/services/chat-channels/message-lifecycle.ts
@@ -0,0 +1,39 @@
+import type { ChatProvider } from "@paperclipai/shared";
+import type { Author } from "chat";
+import { normalizeMicrosoftTeamsExternalPrincipalId } from "../chat-teams-credentials.js";
+
+
+// Provider message identity and chronology only. Callers retain authentication,
+// authorization, durable admission, and delivery ownership.
+export const MAX_INBOUND_TEXT = 100_000;
+
+export function stableExternalPrincipalId(
+ provider: ChatProvider,
+ author: Author,
+ raw?: unknown,
+): string {
+ if (provider !== "microsoft-teams" || !raw || typeof raw !== "object")
+ return author.userId;
+ const from = (raw as { from?: unknown }).from;
+ if (!from || typeof from !== "object") return author.userId;
+ const aadObjectId = (from as { aadObjectId?: unknown }).aadObjectId;
+ return normalizeMicrosoftTeamsExternalPrincipalId(aadObjectId, author.userId);
+}
+
+export type LifecycleActor = {
+ displayName: string;
+ externalId: string;
+ handle: string;
+};
+
+export function lifecycleActorFromAuthor(
+ provider: ChatProvider,
+ author: Author,
+ raw?: unknown,
+): LifecycleActor {
+ return {
+ externalId: stableExternalPrincipalId(provider, author, raw),
+ displayName: author.fullName,
+ handle: author.userName,
+ };
+}
diff --git a/server/src/services/chat-channels/microsoft-teams/messages.ts b/server/src/services/chat-channels/microsoft-teams/messages.ts
new file mode 100644
index 00000000000..c5ed87cebf9
--- /dev/null
+++ b/server/src/services/chat-channels/microsoft-teams/messages.ts
@@ -0,0 +1,99 @@
+import { createHash } from "node:crypto";
+import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
+import { MAX_INBOUND_TEXT, lifecycleActorFromAuthor, type LifecycleActor } from "../message-lifecycle.js";
+
+
+type MicrosoftTeamsLifecycleEvent = {
+ actor?: LifecycleActor;
+ eventKind: "message_updated" | "message_deleted" | "message_restored";
+ messageId: string;
+ providerSentAt: string | null;
+ raw?: unknown;
+ revision: string;
+ text: string;
+ threadId: string;
+};
+
+export function microsoftTeamsLifecycleEventFromPayload(
+ payload: unknown,
+ endpointRuntime: Pick,
+): MicrosoftTeamsLifecycleEvent | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
+ return null;
+ const activity = payload as {
+ channelData?: { eventType?: unknown };
+ timestamp?: unknown;
+ type?: unknown;
+ };
+ const eventType = activity.channelData?.eventType;
+ const isEdit =
+ activity.type === "messageUpdate" && eventType === "editMessage";
+ const isRestore =
+ activity.type === "messageUpdate" && eventType === "undeleteMessage";
+ const isDelete =
+ activity.type === "messageDelete" && eventType === "softDeleteMessage";
+ if (!isEdit && !isRestore && !isDelete) return null;
+ const message = endpointRuntime.parseMicrosoftTeamsMessage(payload);
+ if (!message?.id || !message.threadId) return null;
+ const body = message.text.slice(0, MAX_INBOUND_TEXT);
+ const providerSentAt =
+ typeof activity.timestamp === "string" &&
+ Number.isFinite(Date.parse(activity.timestamp))
+ ? new Date(activity.timestamp).toISOString()
+ : null;
+ const bodyHash = createHash("sha256").update(body).digest("hex");
+ const revision = providerSentAt ? `${providerSentAt}:${bodyHash}` : bodyHash;
+ return {
+ ...(!isDelete
+ ? {
+ actor: lifecycleActorFromAuthor(
+ "microsoft-teams",
+ message.author,
+ message.raw,
+ ),
+ }
+ : {}),
+ eventKind: isDelete
+ ? "message_deleted"
+ : isRestore
+ ? "message_restored"
+ : "message_updated",
+ messageId: message.id,
+ providerSentAt,
+ raw: message.raw,
+ revision,
+ text: isDelete
+ ? "An external message in this conversation was deleted."
+ : isRestore
+ ? `An external message was restored:\n\n${body}`
+ : `An external message was edited:\n\n${body}`,
+ threadId: message.threadId,
+ };
+}
+
+export function teamsActivitySentAt(raw: unknown): Date | null {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const value = (raw as { timestamp?: unknown }).timestamp;
+ // The SDK may supply a Date, but its missing-timestamp display fallback is
+ // only on Message.metadata. Never use that fallback as provider chronology.
+ if (value instanceof Date)
+ return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : null;
+ if (
+ typeof value !== "string" ||
+ value.length > 64 ||
+ !/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,7})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(
+ value,
+ )
+ )
+ return null;
+ // Date.parse otherwise normalizes nonexistent dates such as February 30.
+ const day = value.slice(0, 10);
+ const calendar = new Date(`${day}T00:00:00.000Z`);
+ if (
+ !Number.isFinite(calendar.getTime()) ||
+ calendar.toISOString().slice(0, 10) !== day
+ )
+ return null;
+ const sentAt = new Date(value);
+ return Number.isFinite(sentAt.getTime()) ? sentAt : null;
+}
diff --git a/server/src/services/chat-channels/slack/messages.ts b/server/src/services/chat-channels/slack/messages.ts
new file mode 100644
index 00000000000..eba1e833f2f
--- /dev/null
+++ b/server/src/services/chat-channels/slack/messages.ts
@@ -0,0 +1,43 @@
+import { createHash } from "node:crypto";
+
+
+export function slackLifecycleFilesDigest(raw: unknown): string {
+ const files =
+ raw && typeof raw === "object" && !Array.isArray(raw)
+ ? (raw as Record).files
+ : undefined;
+ // Match the pinned Slack adapter's content-change projection. Transport
+ // locators/unfurls are not content identity and must never enter durable keys.
+ const projection = Array.isArray(files)
+ ? files.map((file) => {
+ const row =
+ file && typeof file === "object" && !Array.isArray(file)
+ ? (file as Record)
+ : {};
+ return [
+ ...["id", "name", "mimetype"].map((key) =>
+ typeof row[key] === "string" ? row[key] : null,
+ ),
+ ...["size", "original_w", "original_h"].map((key) =>
+ typeof row[key] === "number" && Number.isFinite(row[key])
+ ? row[key]
+ : null,
+ ),
+ ];
+ })
+ : [];
+ return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
+}
+
+export function slackMessageSentAt(raw: unknown, messageId: string): Date | null {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const ts = (raw as { ts?: unknown }).ts;
+ if (
+ ts !== messageId ||
+ typeof ts !== "string" ||
+ !/^[1-9][0-9]{0,12}\.[0-9]{6}$/.test(ts)
+ )
+ return null;
+ const date = new Date(Number(ts) * 1_000);
+ return Number.isFinite(date.getTime()) ? date : null;
+}
diff --git a/server/src/services/chat-channels/telegram/messages.ts b/server/src/services/chat-channels/telegram/messages.ts
new file mode 100644
index 00000000000..152b6478533
--- /dev/null
+++ b/server/src/services/chat-channels/telegram/messages.ts
@@ -0,0 +1,223 @@
+import { createHash } from "node:crypto";
+import type { chatDeliveries } from "@paperclipai/db";
+import type { ChatProvider } from "@paperclipai/shared";
+import { normalizeTelegramRichMessage } from "../../chat-telegram-rich-intake.js";
+import { MAX_INBOUND_TEXT, type LifecycleActor } from "../message-lifecycle.js";
+
+
+type TelegramLifecycleEvent = {
+ actor: LifecycleActor;
+ eventKind: "message_updated";
+ messageId: string;
+ providerEventId?: string;
+ providerMessageSequence: number;
+ providerSentAt: string;
+ providerUpdateId: number | null;
+ revision: string;
+ text: string;
+ threadId: string;
+};
+
+function telegramLifecycleActor(message: {
+ from?: unknown;
+ sender_chat?: unknown;
+}): LifecycleActor | null {
+ const candidate =
+ message.from &&
+ typeof message.from === "object" &&
+ !Array.isArray(message.from)
+ ? (message.from as Record)
+ : message.sender_chat &&
+ typeof message.sender_chat === "object" &&
+ !Array.isArray(message.sender_chat)
+ ? (message.sender_chat as Record)
+ : null;
+ if (!candidate) return null;
+ const id = candidate.id;
+ if (typeof id !== "string" && typeof id !== "number") return null;
+ const handle =
+ typeof candidate.username === "string" ? candidate.username : "";
+ const displayName =
+ [candidate.first_name, candidate.last_name]
+ .filter(
+ (value): value is string => typeof value === "string" && Boolean(value),
+ )
+ .join(" ") ||
+ (typeof candidate.title === "string" ? candidate.title : "") ||
+ handle ||
+ String(id);
+ return { externalId: String(id), displayName, handle };
+}
+
+export function telegramLifecycleEventFromPayload(
+ payload: unknown,
+): TelegramLifecycleEvent | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
+ return null;
+ const update = payload as {
+ edited_message?: unknown;
+ update_id?: unknown;
+ };
+ const edited = update.edited_message;
+ if (!edited || typeof edited !== "object" || Array.isArray(edited))
+ return null;
+ const message = edited as {
+ caption?: unknown;
+ chat?: { id?: unknown };
+ edit_date?: unknown;
+ from?: unknown;
+ message_id?: unknown;
+ message_thread_id?: unknown;
+ sender_chat?: unknown;
+ text?: unknown;
+ rich_message?: unknown;
+ };
+ const chatId = message.chat?.id;
+ const messageId = message.message_id;
+ const editDate = message.edit_date;
+ if (
+ (typeof chatId !== "string" && typeof chatId !== "number") ||
+ typeof messageId !== "number" ||
+ !Number.isSafeInteger(messageId) ||
+ messageId < 0 ||
+ typeof editDate !== "number" ||
+ !Number.isSafeInteger(editDate) ||
+ editDate <= 0
+ )
+ return null;
+ const topicId = message.message_thread_id;
+ if (topicId !== undefined && typeof topicId !== "number") return null;
+ const rich = normalizeTelegramRichMessage(message);
+ const body =
+ rich?.text ??
+ (typeof message.text === "string"
+ ? message.text
+ : typeof message.caption === "string"
+ ? message.caption
+ : "");
+ const chat = String(chatId);
+ const providerUpdateId =
+ typeof update.update_id === "number" &&
+ Number.isSafeInteger(update.update_id) &&
+ update.update_id >= 0
+ ? update.update_id
+ : null;
+ const bodyHash = createHash("sha256").update(body).digest("hex");
+ const actor = telegramLifecycleActor(message);
+ if (!actor) return null;
+ return {
+ actor,
+ eventKind: "message_updated",
+ messageId: `${chat}:${messageId}`,
+ ...(providerUpdateId === null
+ ? {}
+ : { providerEventId: `telegram:update:${providerUpdateId}` }),
+ providerMessageSequence: messageId,
+ providerSentAt: new Date(editDate * 1_000).toISOString(),
+ providerUpdateId,
+ // Bot API timestamps have one-second resolution. The update id is the
+ // authoritative identity when present; retaining a content hash keeps the
+ // fallback path from collapsing two distinct edits in that same second.
+ revision: `${editDate}:${bodyHash}${rich ? `:${rich.mediaDigest}` : ""}`,
+ text: `An external message was edited:\n\n${body.slice(0, MAX_INBOUND_TEXT)}`,
+ threadId:
+ topicId === undefined
+ ? `telegram:${chat}`
+ : `telegram:${chat}:${topicId}`,
+ };
+}
+
+export function isTelegramEditedMessageRaw(raw: unknown): boolean {
+ return (
+ !!raw &&
+ typeof raw === "object" &&
+ !Array.isArray(raw) &&
+ typeof (raw as { edit_date?: unknown }).edit_date === "number"
+ );
+}
+
+export function isTelegramMigrationRaw(raw: unknown): boolean {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
+ const value = raw as {
+ migrate_from_chat_id?: unknown;
+ migrate_to_chat_id?: unknown;
+ };
+ return (
+ value.migrate_from_chat_id !== undefined ||
+ value.migrate_to_chat_id !== undefined
+ );
+}
+
+export function telegramMessageSequence(raw: unknown): number | null {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const value = (raw as { message_id?: unknown }).message_id;
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
+ ? value
+ : null;
+}
+
+export function telegramMessageId(raw: unknown): string | null {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const value = raw as {
+ chat?: { id?: unknown };
+ message_id?: unknown;
+ };
+ const messageId = value.message_id;
+ const chatId = value.chat?.id;
+ if (
+ typeof messageId !== "number" ||
+ !Number.isSafeInteger(messageId) ||
+ messageId <= 0
+ )
+ return null;
+ const normalizedChatId =
+ typeof chatId === "number" && Number.isSafeInteger(chatId)
+ ? String(chatId)
+ : typeof chatId === "string" && /^-?\d+$/.test(chatId)
+ ? chatId
+ : null;
+ return normalizedChatId ? `${normalizedChatId}:${messageId}` : null;
+}
+
+export function telegramZeroMessageId(value: unknown): boolean {
+ // The pinned adapter qualifies native message IDs with the numeric chat ID.
+ // Keep legacy synthetic/non-numeric IDs compatible; zero is specifically
+ // the provider's non-ordinary message identity, never an admitted source.
+ return typeof value === "string" && /^(?:-?\d+:)?0$/.test(value);
+}
+
+export function telegramDeliveryHasZeroMessageId(
+ delivery: Pick,
+ provider: ChatProvider,
+): boolean {
+ if (provider !== "telegram") return false;
+ const normalized = delivery.normalizedEvent as {
+ message?: {
+ providerMessageId?: unknown;
+ providerMessageSequence?: unknown;
+ };
+ conversation?: { externalThreadId?: unknown };
+ };
+ if (
+ telegramZeroMessageId(normalized.message?.providerMessageId) ||
+ normalized.message?.providerMessageSequence === 0
+ )
+ return true;
+ const threadId = normalized.conversation?.externalThreadId;
+ // Check the independently retained event key only inside its exact thread
+ // namespace, not arbitrary IDs ending in ':0' from synthetic commands.
+ return (
+ typeof threadId === "string" &&
+ delivery.providerEventId.startsWith(`${threadId}:`) &&
+ telegramZeroMessageId(delivery.providerEventId.slice(threadId.length + 1))
+ );
+}
+
+export function telegramMessageSentAt(raw: unknown): Date | null {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const value = (raw as { date?: unknown }).date;
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
+ return null;
+ const sentAt = new Date(value * 1_000);
+ return Number.isFinite(sentAt.getTime()) ? sentAt : null;
+}
From 3a213ab987080150387c16e45b993eaf1435dc30 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:39:51 -0500
Subject: [PATCH 05/23] refactor(chat): extract resource reconciliation and
availability (#15834)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip manages AI agents and their work.
> - Chat resources control which provider destinations can admit that
work.
> - Inventory updates and lifecycle callbacks must preserve company
scope and operator choices.
> - These rules share a large service with runtime and credential
ownership.
> - This PR extracts resource reconciliation and its availability
transaction.
> - Reviewers can inspect resource changes together while the service
retains runtime control.
## Linked Issues or Issue Description
Refs #15823 (merged). Slice 2 of 5 now targets `master`. Remaining
order: #15834 → #15836 → #15844 → #15845.
## What Changed
- Extract shared resource inventory, audited selection and availability
settlement from the large service.
- Put GitHub repository parsing, rename/transfer repair, webhook
recovery and listing in `github/resources.ts`.
- Put Telegram group migration in `telegram/resources.ts`, using the
coordinator's existing transaction and timestamp.
- Put Slack labels, Teams IDs and Photon participant parsing in their
channel folders.
- Keep lease assertions, delivery settlement and the resource upsert
between Telegram migration steps in the shared coordinator.
- The shared module is 598 lines; provider resource modules total 409
lines. Update the dated plan with this reassessment.
## Verification
- On updated master, 15 existing resource integration cases and one
Telegram migration case passed before this folder move. The same 16
cases passed after. Other cases were excluded by the filter.
- Eight moved declarations and three transaction blocks match the prior
implementation. The GitHub provider guard remains in the caller.
- Server TypeScript, module-boundary and whitespace checks passed.
Rebuilt plugin SDK types after rebasing onto current master.
- Historical browser shard 8 timed out on inbox retry; its unchanged
[rerun
passed](https://github.com/paperclipai/paperclip/actions/runs/38134430238/job/114457529315).
[Original
failure](https://github.com/paperclipai/paperclip/actions/runs/38134430238/job/114455332214).
- Tests use local disposable databases and fixture provider transports.
No live third-party execution is claimed.
- Fresh Greptile review is 5/5 on
`e914f532ca66c429125492c1723df38afc6e13d7`, with a successful check and
no open review threads.
- Final-stack `pnpm -r typecheck` and `pnpm build` passed at
`57ec5ba94aecd860471f9b14db46f99120c7c790`.
- All CI checks passed on `e914f532ca66c429125492c1723df38afc6e13d7`.
- The final full-local-suite outcome for this cumulative stack is
recorded in #15845; do not infer that gate from the focused tests above.
## Risks
- Resource identity and migrations depend on lock and write order.
Provider modules use the original transaction; statement order and
credential fencing remain unchanged.
- Shared selection and availability still contain provider dispatch
predicates. They coordinate common state transitions and should remain
shared.
- Full local tests and current-revision CI remain required for a green
handoff.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning and repository tools. The
exact runtime model ID and context-window size are not exposed in this
session. No separate agent was used.
## 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
- [ ] 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
---
.../2026-10-10-chat-channels-refactor.md | 47 +
server/src/services/chat-channels.ts | 931 +-----------------
.../chat-channels/github/resources.ts | 278 ++++++
.../imessage-photon/resources.ts | 14 +
.../microsoft-teams/resources.ts | 3 +
.../chat-channels/resource-reconciliation.ts | 598 +++++++++++
.../services/chat-channels/slack/resources.ts | 10 +
.../chat-channels/telegram/resources.ts | 104 ++
8 files changed, 1065 insertions(+), 920 deletions(-)
create mode 100644 server/src/services/chat-channels/github/resources.ts
create mode 100644 server/src/services/chat-channels/imessage-photon/resources.ts
create mode 100644 server/src/services/chat-channels/microsoft-teams/resources.ts
create mode 100644 server/src/services/chat-channels/resource-reconciliation.ts
create mode 100644 server/src/services/chat-channels/slack/resources.ts
create mode 100644 server/src/services/chat-channels/telegram/resources.ts
diff --git a/doc/plans/2026-10-10-chat-channels-refactor.md b/doc/plans/2026-10-10-chat-channels-refactor.md
index 711c6e6b125..2b6765a72ea 100644
--- a/doc/plans/2026-10-10-chat-channels-refactor.md
+++ b/doc/plans/2026-10-10-chat-channels-refactor.md
@@ -125,3 +125,50 @@ this slice to `paperclipai/paperclip`; subsequent extractions are not part of
this PR.
Review and final verification: [PR #15823](https://github.com/paperclipai/paperclip/pull/15823).
+
+## Authorized stacked continuation
+
+The user requested at least five stacked PRs on 2026-10-10. Continue the four
+remaining boundaries in order, with each branch based on its predecessor. Keep
+merge and deployment outside this series. Each PR carries its own current-head
+verification and review results; do not transfer a prior head's pass to a new one.
+
+### Slice 2: resource reconciliation and availability
+
+The lifecycle handler also owns credential replacement, delivery claims and
+runtime shutdown. Keep those responsibilities in the service. Extract the full
+non-GitHub availability transaction, inventory reconciliation, repository rename
+handling, provider ordering, resource listing and audited selection together.
+The factory takes the database, endpoint lookup, existing credential lease
+function, generation reader and error text limit. It creates no worker or cache.
+
+- Source: 38,633 lines / 1,491,422 bytes → 37,720 lines / 1,454,762 bytes.
+- Destination: 947 lines / 39,584 bytes in `resource-reconciliation.ts`.
+- Mechanical comparison: 14 declarations and the complete availability
+ transaction are unchanged; all other function bodies remain unchanged.
+- Baseline: all three complete chat CI shards passed on predecessor `cbba30a526`.
+- Local baseline and after selections could not start PostgreSQL and skipped
+ the suite. They are not passes. Server typecheck and module boundaries pass.
+- Require complete chat CI on this slice before starting the next extraction.
+
+### 2026-10-12 channel-folder reassessment: slice 2
+
+PR #15823 was merged at the user's request. Rebase this slice onto the merged
+master, then retain the remaining four PRs for review in dependency order.
+
+- GitHub payload parsing, repository coordinate repair, webhook recovery,
+ repository pagination and bulk-selection entry points live in
+ `chat-channels/github/resources.ts`.
+- Telegram migration preparation and conversation migration live in
+ `telegram/resources.ts`. They receive the existing transaction and timestamp;
+ the shared coordinator still upserts the destination between those steps.
+- Slack fallback labels, Teams conversation IDs and Photon participant parsing
+ live in each provider's `resources.ts`.
+- Shared inventory replacement, availability settlement and audited selection
+ retain their transaction and credential-lease ownership. Small provider
+ dispatch predicates remain next to the shared operation they select.
+- All eight moved declarations and three transaction blocks retain their logic.
+ The GitHub provider guard stays in the caller. No factory starts background work.
+- Existing public-service tests: 15 resource cases and the Telegram migration
+ case passed before; the same 16 cases passed after. The name filter excludes
+ unrelated cases. No live third-party provider calls were made.
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index 9716073c91f..a0b5c93324c 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -1,3 +1,8 @@
+import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js";
+import { githubRepositoryInventoryItemFromPayload } from "./chat-channels/github/resources.js";
+import { baseTeamsConversationId } from "./chat-channels/microsoft-teams/resources.js";
+import { slackResourceLabelIsFallback } from "./chat-channels/slack/resources.js";
+import { telegramResourceLabelIsFallback } from "./chat-channels/telegram/resources.js";
import {
MAX_INBOUND_TEXT,
stableExternalPrincipalId,
@@ -53,7 +58,7 @@ import type { AgentAvatarRequest } from "./agent-avatars.js";
import { slackChatRegistrationService, slackRegistrationProjection } from "./chat-slack-registration.js";
import { chatSlackRegistrations } from "@paperclipai/db";
import { SLACK_CHAT_BOT_SCOPES } from "@paperclipai/shared";
-import { authorizationService, canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js";
+import { canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js";
import { withSlackBoardLease } from "./slack-board-lease.js";
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
import { authorizeSlackBoardPublication } from "./slack-board-authority.js";
@@ -64,7 +69,7 @@ import { slackAuthorizationRevision } from "./connectors/slack-revision.js";
import { slackPublicationAllowed } from "./connectors/slack-access.js";
import { instanceSettingsService } from "./instance-settings.js";
import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-authority.js";
-import { captureRunIdentity } from "./run-identity.js";
+
import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js";
function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; }
import { githubChatManagementService } from "./chat-github-management.js";
@@ -102,7 +107,7 @@ import { validateNativeQuestionResponseInput } from "./native-runtime/native-que
import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared";
import { PhotonCloudClient, PhotonError, photonFailure, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js";
import { PhotonChatAdapter, photonThreadId, photonReplyReference } from "./photon/adapter.js";
-import { photonChannelConfigurationSchema, type PhotonChannelConfiguration } from "@paperclipai/shared";
+import { photonChannelConfigurationSchema } from "@paperclipai/shared";
import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage";
import { defaultSlackAppConfiguration } from "@paperclipai/shared";
import {
@@ -346,14 +351,7 @@ import {
restoreGitHubAttachmentLimitOmissions,
} from "./chat-github-attachments.js";
import { getExternalChannelBindingSummary } from "./chat-channel-binding.js";
-import {
- discoverDedicatedGitHubAppInstallation,
- getSlackBotChannel,
- listGitHubInstallationRepositories,
- listSlackBotChannels,
- type ChatProviderInventoryResult,
- type ChatProviderResourceInventoryItem,
-} from "./chat-provider-inventory.js";
+import { discoverDedicatedGitHubAppInstallation, getSlackBotChannel, listGitHubInstallationRepositories, listSlackBotChannels, type ChatProviderInventoryResult } from "./chat-provider-inventory.js";
import {
discordMarkdownRequiresAttachment,
listDiscordBotChannels,
@@ -1623,10 +1621,6 @@ function microsoftTeamsServiceUrl(raw: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
-function baseTeamsConversationId(value: string): string {
- return value.replace(/;messageid=[^;]+/i, "");
-}
-
function teamsThreadRootMessageId(threadId: string): string | null {
const conversationId = teamsConversationId(threadId);
if (!conversationId) return null;
@@ -1656,28 +1650,6 @@ function canonicalProviderResourceId(
return thread.channelId;
}
-function slackResourceLabelIsFallback(
- providerResourceId: string,
- label: string,
-): boolean {
- const candidate = label.trim();
- return (
- candidate === providerResourceId ||
- candidate === `slack:${providerResourceId}`
- );
-}
-
-function telegramResourceLabelIsFallback(
- providerResourceId: string,
- label: string,
-): boolean {
- const candidate = label.trim();
- return (
- candidate === providerResourceId ||
- candidate === `telegram:${providerResourceId}`
- );
-}
-
function providerResourceLabelFromThread(
provider: ChatProvider,
thread: Pick,
@@ -2053,67 +2025,6 @@ function isSupportedTelegramWebhookBaseUrl(value: string | null): boolean {
}
}
-function githubRepositoryInventoryItemFromPayload(
- payload: unknown,
-): ChatProviderResourceInventoryItem | null {
- if (!payload || typeof payload !== "object" || Array.isArray(payload))
- return null;
- const repository = (payload as { repository?: unknown }).repository;
- if (
- !repository ||
- typeof repository !== "object" ||
- Array.isArray(repository)
- )
- return null;
- const value = repository as {
- id?: unknown;
- full_name?: unknown;
- html_url?: unknown;
- name?: unknown;
- owner?: { id?: unknown; login?: unknown };
- private?: unknown;
- };
- const repositoryId =
- typeof value.id === "number" && Number.isSafeInteger(value.id)
- ? String(value.id)
- : typeof value.id === "string" && /^\d+$/.test(value.id)
- ? value.id
- : null;
- const owner =
- typeof value.owner?.login === "string" ? value.owner.login.trim() : "";
- const name = typeof value.name === "string" ? value.name.trim() : "";
- const fullName =
- typeof value.full_name === "string" && value.full_name.includes("/")
- ? value.full_name.trim()
- : owner && name
- ? `${owner}/${name}`
- : "";
- if (!repositoryId || !fullName) return null;
- const ownerId =
- typeof value.owner?.id === "number" && Number.isSafeInteger(value.owner.id)
- ? String(value.owner.id)
- : typeof value.owner?.id === "string" && /^\d+$/.test(value.owner.id)
- ? value.owner.id
- : undefined;
- return {
- providerResourceId: fullName.toLowerCase(),
- parentProviderResourceId: ownerId,
- type: "repository",
- label: fullName,
- providerUrl:
- typeof value.html_url === "string" && value.html_url.length > 0
- ? value.html_url
- : `https://github.com/${fullName}`,
- metadata: {
- providerRepositoryId: repositoryId,
- fullName,
- ...(owner ? { owner } : {}),
- private: value.private === true,
- source: "provider_webhook",
- },
- };
-}
-
function providerSetupState(
endpoint: Pick<
EndpointRow,
@@ -6283,6 +6194,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}
const withCredentialMutationLease = chatCredentialMutationLease(db, options);
+ const { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability } = createChatResourceReconciliation(db, { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT });
async function generateSetupSecret(
endpointId: string,
@@ -6563,273 +6475,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
);
}
- function githubRepositoryStableId(
- item: ChatProviderResourceInventoryItem,
- ): string | null {
- const value = item.metadata?.providerRepositoryId;
- return typeof value === "string" && /^\d+$/.test(value) ? value : null;
- }
-
- async function upsertProviderResourceRow(
- tx: DbTransaction,
- endpoint: EndpointRow,
- item: ChatProviderResourceInventoryItem,
- ) {
- const stableGitHubId =
- endpoint.provider === "github" ? githubRepositoryStableId(item) : null;
- if (stableGitHubId) {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtextextended(${`chat-github-repository:${endpoint.id}:${stableGitHubId}`}, 0))`,
- );
- const stableResource = await tx
- .select()
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpoint.id),
- eq(chatEndpointResources.type, "repository"),
- sql`${chatEndpointResources.metadata}->>'providerRepositoryId' = ${stableGitHubId}`,
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- if (
- stableResource &&
- stableResource.providerResourceId !== item.providerResourceId
- ) {
- const coordinateResource = await tx
- .select()
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpoint.id),
- eq(chatEndpointResources.type, "repository"),
- eq(
- chatEndpointResources.providerResourceId,
- item.providerResourceId,
- ),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- if (coordinateResource && coordinateResource.id !== stableResource.id) {
- const coordinateConversation = await tx
- .select({ id: chatConversations.id })
- .from(chatConversations)
- .where(
- and(
- eq(chatConversations.endpointId, endpoint.id),
- eq(chatConversations.resourceId, coordinateResource.id),
- ),
- )
- .limit(1)
- .then((rows) => rows[0] ?? null);
- if (coordinateConversation) {
- throw conflict(
- "GitHub repository identity is already bound to two coordinates",
- { code: "chat_github_repository_identity_conflict" },
- );
- }
- await tx
- .delete(chatEndpointResources)
- .where(eq(chatEndpointResources.id, coordinateResource.id));
- }
-
- const oldRepository = stableResource.providerResourceId;
- const oldThreadPrefix = `github:${oldRepository}`;
- const newThreadPrefix = `github:${item.providerResourceId}`;
- const oldProviderUrl = `https://github.com/${oldRepository}`;
- const newProviderUrl = `https://github.com/${item.providerResourceId}`;
- await tx
- .update(chatEndpointResources)
- .set({
- providerResourceId: item.providerResourceId,
- parentProviderResourceId: item.parentProviderResourceId ?? null,
- label: item.label,
- providerUrl: item.providerUrl ?? null,
- availability: "available",
- enabled:
- stableResource.enabled || coordinateResource?.enabled === true,
- metadata: item.metadata ?? {},
- updatedAt: new Date(),
- })
- .where(eq(chatEndpointResources.id, stableResource.id));
- await tx
- .update(chatConversations)
- .set({
- externalConversationId: sql`case
- when lower(${chatConversations.externalConversationId}) = ${oldRepository} then ${item.providerResourceId}
- when lower(${chatConversations.externalConversationId}) = ${oldThreadPrefix} then ${newThreadPrefix}
- else ${chatConversations.externalConversationId}
- end`,
- externalThreadId: sql`case
- when lower(${chatConversations.externalThreadId}) = ${oldThreadPrefix} then ${newThreadPrefix}
- when lower(${chatConversations.externalThreadId}) like ${`${oldThreadPrefix}:%`}
- then ${newThreadPrefix} || substring(
- ${chatConversations.externalThreadId}
- from char_length(${oldThreadPrefix}) + 1
- )
- else ${chatConversations.externalThreadId}
- end`,
- externalLabel: item.label,
- providerUrl: sql`case
- when lower(${chatConversations.providerUrl}) like ${`${oldProviderUrl}/%`}
- then ${newProviderUrl} || substring(
- ${chatConversations.providerUrl}
- from char_length(${oldProviderUrl}) + 1
- )
- else ${chatConversations.providerUrl}
- end`,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(chatConversations.companyId, endpoint.companyId),
- eq(chatConversations.endpointId, endpoint.id),
- eq(chatConversations.resourceId, stableResource.id),
- ),
- );
- return { id: stableResource.id };
- }
- }
-
- const [resource] = await tx
- .insert(chatEndpointResources)
- .values({
- companyId: endpoint.companyId,
- endpointId: endpoint.id,
- type: item.type,
- providerResourceId: item.providerResourceId,
- parentProviderResourceId: item.parentProviderResourceId ?? null,
- label: item.label,
- providerUrl: item.providerUrl ?? null,
- availability: "available",
- // Slack inventory contains only channels the bot has joined. Apply the
- // invitation default on insert; conflict updates preserve operator choices.
- enabled:
- endpoint.provider === "slack" &&
- item.type === "channel" &&
- item.metadata?.creator !== endpoint.botExternalId,
- metadata: item.metadata ?? {},
- })
- .onConflictDoUpdate({
- target: [
- chatEndpointResources.endpointId,
- chatEndpointResources.type,
- chatEndpointResources.providerResourceId,
- ],
- set: {
- parentProviderResourceId: item.parentProviderResourceId ?? null,
- label: item.label,
- providerUrl: item.providerUrl ?? null,
- availability: "available",
- metadata: item.metadata ?? {},
- updatedAt: new Date(),
- },
- })
- .returning({ id: chatEndpointResources.id });
- return resource ?? null;
- }
-
- async function reconcileGitHubWebhookRepository(
- endpoint: EndpointRow,
- payload: unknown,
- credentialLease: CredentialMutationLeaseGuard,
- ) {
- const item = githubRepositoryInventoryItemFromPayload(payload);
- if (!item) return;
- await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
- const resource = await upsertProviderResourceRow(tx, endpoint, item);
- if (!resource) return;
- // A correctly signed repository callback is current provider proof. Keep
- // Paperclip's enabled choice, and reopen only conversations previously
- // quarantined for provider availability loss.
- await tx
- .update(chatConversations)
- .set({ state: "active", updatedAt: new Date() })
- .where(
- and(
- eq(chatConversations.companyId, endpoint.companyId),
- eq(chatConversations.endpointId, endpoint.id),
- eq(chatConversations.resourceId, resource.id),
- eq(chatConversations.state, "unavailable"),
- ),
- );
- await credentialLease.assertOwned(tx);
- });
- }
-
- async function reconcileProviderResourceRows(
- endpoint: EndpointRow,
- inventory: ChatProviderInventoryResult,
- credentialLease: CredentialMutationLeaseGuard,
- ) {
- const resourceType =
- endpoint.provider === "github" ? "repository" : "channel";
- const discovered = new Set(
- inventory.resources.map((resource) => resource.providerResourceId),
- );
- const absentAvailability =
- endpoint.provider === "github" ? "removed" : "unavailable";
- await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
- for (const item of inventory.resources) {
- const resource = await upsertProviderResourceRow(tx, endpoint, item);
- if (resource) {
- // Resource recovery reopens only bindings that Paperclip marked
- // unavailable. Completed historical tasks stay completed.
- await tx
- .update(chatConversations)
- .set({ state: "active", updatedAt: new Date() })
- .where(
- and(
- eq(chatConversations.companyId, endpoint.companyId),
- eq(chatConversations.endpointId, endpoint.id),
- eq(chatConversations.resourceId, resource.id),
- eq(chatConversations.state, "unavailable"),
- ),
- );
- }
- }
-
- const existing = await tx
- .select({
- id: chatEndpointResources.id,
- providerResourceId: chatEndpointResources.providerResourceId,
- })
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpoint.id),
- eq(chatEndpointResources.type, resourceType),
- ),
- );
- for (const resource of existing) {
- if (discovered.has(resource.providerResourceId)) continue;
- await tx
- .update(chatEndpointResources)
- .set({ availability: absentAvailability, updatedAt: new Date() })
- .where(eq(chatEndpointResources.id, resource.id));
- await tx
- .update(chatConversations)
- .set({ state: "unavailable", updatedAt: new Date() })
- .where(
- and(
- eq(chatConversations.companyId, endpoint.companyId),
- eq(chatConversations.endpointId, endpoint.id),
- eq(chatConversations.resourceId, resource.id),
- inArray(chatConversations.state, ["active", "waiting"]),
- ),
- );
- }
- await credentialLease.assertOwned(tx);
- });
- }
-
async function hydrateSlackResourceLabel(
endpointId: string,
providerResourceId: string,
@@ -23812,309 +23457,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
effect.kind === "endpoint" &&
(effect.availability === "attention" ||
effect.availability === "revoked");
- await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
- const now = new Date();
- if (staleResourceEffect) {
- // Persisted provider-native ordering wins over request arrival or
- // lease acquisition order. The older callback remains auditable,
- // but cannot roll a resource or conversation back.
- await tx
- .update(chatEndpoints)
- .set({ lastEventAt: now, updatedAt: now })
- .where(eq(chatEndpoints.id, currentEndpoint.id));
- await notifyChatDeliveryWork(tx);
- await tx
- .update(chatDeliveries)
- .set({
- state: "processed",
- processedAt: now,
- redactedError: null,
- nextAttemptAt: null,
- updatedAt: now,
- })
- .where(eq(chatDeliveries.id, claimed.id));
- return;
- }
- if (effect.kind === "resource") {
- const providerResourceId =
- currentEndpoint.provider === "microsoft-teams"
- ? baseTeamsConversationId(effect.providerResourceId)
- : effect.providerResourceId;
- const previousProviderResourceId =
- currentEndpoint.provider === "telegram"
- ? effect.previousProviderResourceId
- : undefined;
- const previousResource = previousProviderResourceId
- ? await tx
- .select()
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.endpointId, currentEndpoint.id),
- eq(chatEndpointResources.type, "chat"),
- eq(
- chatEndpointResources.providerResourceId,
- previousProviderResourceId,
- ),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null)
- : null;
- const migratedLabel =
- previousResource &&
- telegramResourceLabelIsFallback(providerResourceId, effect.label)
- ? previousResource.label
- : effect.label;
- const migratedEnabled = previousResource?.enabled === true;
- if (
- previousResource &&
- previousProviderResourceId !== providerResourceId
- ) {
- await tx
- .update(chatEndpointResources)
- .set({
- availability: "unavailable",
- enabled: false,
- metadata: {
- ...previousResource.metadata,
- source: "chat_migration",
- migratedTo: providerResourceId,
- },
- updatedAt: now,
- })
- .where(eq(chatEndpointResources.id, previousResource.id));
- }
- const preserveProviderLabel =
- (currentEndpoint.provider === "slack" &&
- slackResourceLabelIsFallback(
- providerResourceId,
- migratedLabel,
- )) ||
- (currentEndpoint.provider === "telegram" &&
- telegramResourceLabelIsFallback(
- providerResourceId,
- migratedLabel,
- ));
- const [resource] = await tx
- .insert(chatEndpointResources)
- .values({
- companyId: currentEndpoint.companyId,
- endpointId: currentEndpoint.id,
- type: effect.resourceType,
- providerResourceId,
- parentProviderResourceId:
- effect.parentProviderResourceId ?? null,
- label: migratedLabel,
- providerUrl: effect.providerUrl ?? null,
- availability: effect.availability,
- enabled:
- migratedEnabled ||
- (currentEndpoint.provider === "slack" &&
- effect.resourceType === "channel" &&
- effect.availability === "available"),
- metadata: effect.metadata ?? {},
- })
- .onConflictDoUpdate({
- target: [
- chatEndpointResources.endpointId,
- chatEndpointResources.type,
- chatEndpointResources.providerResourceId,
- ],
- set: {
- parentProviderResourceId:
- effect.parentProviderResourceId ?? null,
- ...(preserveProviderLabel ? {} : { label: migratedLabel }),
- providerUrl: effect.providerUrl ?? null,
- availability: effect.availability,
- ...(previousProviderResourceId
- ? {
- enabled: sql`${chatEndpointResources.enabled} or ${migratedEnabled}`,
- }
- : {}),
- metadata: effect.metadata ?? {},
- updatedAt: now,
- },
- })
- .returning({ id: chatEndpointResources.id });
- if (
- resource &&
- previousResource &&
- previousProviderResourceId !== providerResourceId
- ) {
- const previousThreadPrefix = `telegram:${previousProviderResourceId}`;
- const migratedThreadPrefix = `telegram:${providerResourceId}`;
- await tx
- .update(chatConversations)
- .set({
- resourceId: resource.id,
- // Telegram SDK threads use the namespaced channel id. Keep
- // the migrated row in that canonical shape so the first
- // reply from the replacement supergroup finds this same
- // task instead of creating a second conversation.
- externalConversationId: migratedThreadPrefix,
- externalThreadId: sql`case
- when ${chatConversations.externalThreadId} = ${previousThreadPrefix} then ${migratedThreadPrefix}
- when ${chatConversations.externalThreadId} like ${`${previousThreadPrefix}:%`}
- then ${migratedThreadPrefix} || substring(
- ${chatConversations.externalThreadId}
- from char_length(${previousThreadPrefix}) + 1
- )
- else ${chatConversations.externalThreadId}
- end`,
- externalLabel: migratedLabel,
- state: "active",
- updatedAt: now,
- })
- .where(
- and(
- eq(chatConversations.companyId, currentEndpoint.companyId),
- eq(chatConversations.endpointId, currentEndpoint.id),
- eq(chatConversations.resourceId, previousResource.id),
- ),
- );
- }
- if (resource) {
- await tx
- .update(chatConversations)
- .set({
- state:
- effect.availability === "available"
- ? "active"
- : "unavailable",
- updatedAt: now,
- })
- .where(
- and(
- eq(chatConversations.companyId, currentEndpoint.companyId),
- eq(chatConversations.endpointId, currentEndpoint.id),
- eq(chatConversations.resourceId, resource.id),
- effect.availability === "available"
- ? eq(chatConversations.state, "unavailable")
- : inArray(chatConversations.state, ["active", "waiting"]),
- ),
- );
- }
- await tx
- .update(chatEndpoints)
- .set({ lastEventAt: now, updatedAt: now })
- .where(eq(chatEndpoints.id, currentEndpoint.id));
- } else {
- const reason = redactSensitiveText(effect.reason).slice(
- 0,
- MAX_ERROR_TEXT,
- );
- if (effect.availability === "available") {
- if (
- currentEndpoint.status !== "paused" &&
- currentEndpoint.status !== "archived"
- ) {
- const status =
- currentEndpoint.setup.step === "complete"
- ? "active"
- : currentEndpoint.status === "attention"
- ? "verifying"
- : currentEndpoint.status;
- if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx);
- await tx
- .update(chatEndpoints)
- .set({
- status,
- healthMessage:
- status === "active"
- ? "Connected"
- : "Waiting for a test conversation",
- lastError: null,
- lastEventAt: now,
- updatedAt: now,
- })
- .where(eq(chatEndpoints.id, currentEndpoint.id));
- await tx
- .update(toolConnections)
- .set({
- status: "active",
- enabled: true,
- healthStatus: "healthy",
- healthMessage: "Connected",
- lastError: null,
- healthCheckedAt: now,
- updatedAt: now,
- })
- .where(eq(toolConnections.id, currentEndpoint.connectionId));
- }
- } else {
- await tx
- .update(chatEndpoints)
- .set({
- status: effect.availability,
- healthMessage: reason,
- lastError: reason,
- lastEventAt: now,
- setup: {
- ...currentEndpoint.setup,
- runtimeGeneration:
- runtimeGeneration(currentEndpoint.setup) + 1,
- } as InternalSetupState,
- updatedAt: now,
- })
- .where(eq(chatEndpoints.id, currentEndpoint.id));
- await tx
- .update(toolConnections)
- .set({
- status: "disabled",
- enabled: false,
- healthStatus:
- effect.availability === "revoked" ? "failed" : "degraded",
- healthMessage: reason,
- lastError: reason,
- healthCheckedAt: now,
- updatedAt: now,
- })
- .where(eq(toolConnections.id, currentEndpoint.connectionId));
- await tx
- .update(chatEndpointResources)
- .set({ availability: "unavailable", updatedAt: now })
- .where(
- and(
- eq(
- chatEndpointResources.companyId,
- currentEndpoint.companyId,
- ),
- eq(chatEndpointResources.endpointId, currentEndpoint.id),
- ),
- );
- await tx
- .update(chatConversations)
- .set({ state: "unavailable", updatedAt: now })
- .where(
- and(
- eq(chatConversations.companyId, currentEndpoint.companyId),
- eq(chatConversations.endpointId, currentEndpoint.id),
- inArray(chatConversations.state, ["active", "waiting"]),
- ),
- );
- }
- }
- await notifyChatDeliveryWork(tx);
- await tx
- .update(chatDeliveries)
- .set({
- state: "processed",
- processedAt: now,
- redactedError: null,
- nextAttemptAt: null,
- updatedAt: now,
- })
- .where(
- and(
- eq(chatDeliveries.id, claimed.id),
- eq(chatDeliveries.state, "processing"),
- eq(chatDeliveries.attempts, candidate.attempts + 1),
- ),
- );
- await credentialLease.assertOwned(tx);
- });
+ await applyProviderAvailability({ currentEndpoint, effect, credentialLease, staleResourceEffect, claimed, candidate });
stopRuntimeAfterLifecycle = endpointEffectStopsRuntime;
});
} catch (error) {
@@ -24210,43 +23553,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
return { ...normalized, runtimeContext };
}
- async function hasNewerProcessedProviderLifecycleEffect(
- endpoint: EndpointRow,
- effect: Extract,
- ): Promise {
- const sequence = effect.providerOrder?.sequence;
- const occurredAt = effect.providerOrder?.occurredAt;
- if (!sequence && !occurredAt) return false;
- const providerResourceId =
- endpoint.provider === "microsoft-teams"
- ? baseTeamsConversationId(effect.providerResourceId)
- : effect.providerResourceId;
- const newerOrder = sequence
- ? sql`(
- ${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}' ~ '^[0-9]+([.][0-9]+)?$'
- and (${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}')::numeric > cast(${sequence} as numeric)
- )`
- : sql`(
- nullif(${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,occurredAt}', '')::timestamptz > cast(${occurredAt!} as timestamptz)
- )`;
- const newer = await db
- .select({ id: chatDeliveries.id })
- .from(chatDeliveries)
- .where(
- and(
- eq(chatDeliveries.endpointId, endpoint.id),
- eq(chatDeliveries.state, "processed"),
- inArray(chatDeliveries.eventKind, ["installation", "uninstallation"]),
- sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,kind}' = 'resource'`,
- sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,provider}' = ${effect.provider}`,
- sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerResourceId}' = ${providerResourceId}`,
- newerOrder,
- ),
- )
- .limit(1);
- return newer.length > 0;
- }
-
async function applyProviderLifecycleEffects(
endpoint: EndpointRow,
effects: ChatProviderLifecycleEffect[],
@@ -27715,221 +27021,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
return reactionCount + rows.length;
}
- async function listResources(endpointId: string) {
- const resources = await db
- .select()
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.endpointId, endpointId),
- ne(chatEndpointResources.type, "direct_message"),
- ),
- )
- .orderBy(asc(chatEndpointResources.label));
- return resources.map((resource) => ({
- ...resource,
- participants:
- resource.providerResourceId.startsWith("imessage-photon:") &&
- Array.isArray(resource.metadata.participants)
- ? resource.metadata.participants.flatMap((participant) => {
- const address =
- participant && typeof participant === "object"
- ? (participant as Record).address
- : null;
- return typeof address === "string" ? [address] : [];
- })
- : undefined,
- }));
- }
-
- async function listGitHubRepositories(
- endpointId: string,
- query: { limit: number; offset: number; search: string },
- ) {
- const record = await endpointRecord(endpointId);
- if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
- const scope = and(
- eq(chatEndpointResources.companyId, record.endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpointId),
- eq(chatEndpointResources.type, "repository"),
- );
- // Literal, case-insensitive substring search (%, _ and backslashes are
- // ordinary characters). Counts always describe the entire connection.
- const search = query.search
- ? sql`strpos(lower(${chatEndpointResources.label}), lower(${query.search})) > 0`
- : undefined;
- const [rows, [counts]] = await Promise.all([
- db.select().from(chatEndpointResources).where(and(scope, search))
- .orderBy(asc(chatEndpointResources.label), asc(chatEndpointResources.id))
- .offset(query.offset).limit(query.limit + 1),
- db.select({
- totalCount: sql`count(*)::integer`,
- enabledCount: sql`count(*) filter (where ${chatEndpointResources.enabled})::integer`,
- availableCount: sql`count(*) filter (where ${chatEndpointResources.availability} = 'available')::integer`,
- }).from(chatEndpointResources).where(scope),
- ]);
- return {
- items: rows.slice(0, query.limit),
- nextOffset: rows.length > query.limit ? query.offset + query.limit : null,
- totalCount: counts!.totalCount,
- enabledCount: counts!.enabledCount,
- availableCount: counts!.availableCount,
- };
- }
-
- async function replaceResources(
- endpointId: string,
- updates: Array<{ id: string; enabled: boolean }>,
- actorUserId?: string | null,
- options?: { initialGitHubImport?: boolean },
- ) {
- await updateResourceSelection(endpointId, updates, actorUserId, options);
- return listResources(endpointId);
- }
-
- async function toggleAllGitHubRepositories(endpointId: string, enabled: boolean, actorUserId?: string | null) {
- await updateResourceSelection(endpointId, [], actorUserId, { allGitHubRepositoriesEnabled: enabled });
- return { success: true as const };
- }
-
- async function updateResourceSelection(
- endpointId: string,
- updates: Array<{ id: string; enabled: boolean }>,
- actorUserId?: string | null,
- options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
- ) {
- const initial = await endpointRecord(endpointId);
- if (!initial) throw notFound("Chat endpoint not found");
- const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
- if (bulk && initial.endpoint.provider !== "github") throw badRequest("Only GitHub repositories support toggle all");
- if (updates.length === 0 && initial.endpoint.provider !== "github") return;
- if (initial.endpoint.provider === "imessage-photon" && initial.endpoint.botExternalId?.startsWith("photon-project:") && updates.some((entry) => entry.enabled))
- throw unprocessable("Photon shared channels support direct messages only; groups cannot be enabled");
- await withCredentialMutationLease(
- initial.endpoint,
- async (credentialLease) => {
- const ids = updates.map((entry) => entry.id);
- const publications: ActivityPublication[] = [];
- await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
- const [endpoint] = await tx
- .select({
- companyId: chatEndpoints.companyId,
- connectionId: chatEndpoints.connectionId,
- provider: chatEndpoints.provider,
- setup: chatEndpoints.setup,
- })
- .from(chatEndpoints)
- .where(
- and(
- eq(chatEndpoints.id, endpointId),
- eq(chatEndpoints.companyId, initial.endpoint.companyId),
- ),
- )
- .for("no key update");
- if (!endpoint) throw notFound("Chat endpoint not found");
- if (options?.initialGitHubImport && endpoint.provider === "github" && (endpoint.setup.github?.repositorySelectionSaved || !endpoint.setup.github?.initialRepositoryImportPending)) return;
- const rows = await tx
- .select({
- id: chatEndpointResources.id,
- availability: chatEndpointResources.availability,
- enabled: chatEndpointResources.enabled,
- })
- .from(chatEndpointResources)
- .where(
- and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpointId),
- bulk
- ? and(
- eq(chatEndpointResources.type, "repository"),
- options!.allGitHubRepositoriesEnabled
- ? eq(chatEndpointResources.availability, "available")
- : undefined,
- )
- : inArray(chatEndpointResources.id, ids),
- ),
- )
- .orderBy(asc(chatEndpointResources.id))
- .for("no key update");
- if (!bulk && rows.length !== new Set(ids).size)
- throw unprocessable("Every resource must belong to this endpoint");
- const selectedUpdates = bulk
- ? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! }))
- : updates;
- const availabilityById = new Map(
- rows.map((row) => [row.id, row.availability]),
- );
- // Validate every submitted grant, including intermediate duplicate
- // entries. Netting below describes the audit, not new authority.
- const unavailable = selectedUpdates.find(
- (entry) =>
- entry.enabled && availabilityById.get(entry.id) !== "available",
- );
- if (unavailable)
- throw conflict(
- "A destination must still be available from the provider before it can be enabled",
- { code: "chat_resource_unavailable", resourceId: unavailable.id },
- );
- const finalEnabled = new Map(
- selectedUpdates.map((entry) => [entry.id, entry.enabled]),
- );
- const changes = rows
- .filter((row) => row.enabled !== finalEnabled.get(row.id))
- .map((row) => ({
- resourceId: row.id,
- before: { enabled: row.enabled },
- after: { enabled: finalEnabled.get(row.id)! },
- }));
- if (bulk) {
- // One atomic database update, independent of pagination/search and
- // serialized with refresh, reconnect, and individual row changes.
- await tx.update(chatEndpointResources)
- .set({ enabled: options!.allGitHubRepositoriesEnabled!, updatedAt: new Date() })
- .where(and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpointId),
- eq(chatEndpointResources.type, "repository"),
- options!.allGitHubRepositoriesEnabled
- ? eq(chatEndpointResources.availability, "available")
- : undefined,
- ));
- } else for (const entry of selectedUpdates)
- await tx
- .update(chatEndpointResources)
- .set({ enabled: entry.enabled, updatedAt: new Date() })
- .where(
- and(
- eq(chatEndpointResources.companyId, endpoint.companyId),
- eq(chatEndpointResources.endpointId, endpointId),
- eq(chatEndpointResources.id, entry.id),
- ),
- );
- if (endpoint.provider === "github")
- await tx.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || '{"repositorySelectionSaved":true,"initialRepositoryImportPending":false}'::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
- if (changes.length > 0 || (endpoint.provider === "github" && !endpoint.setup.github?.repositorySelectionSaved))
- await logActivity(
- tx as unknown as Db,
- {
- companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
- action: "chat_endpoint.resources_updated",
- entityType: "tool_connection",
- entityId: endpoint.connectionId,
- details: { endpointId, provider: endpoint.provider, changes },
- },
- publications,
- );
- await credentialLease.assertOwned(tx);
- });
- // A committed reach change is factual even if the outer lease's last
- // check subsequently fails. Never publish an uncommitted activity.
- for (const publication of publications) publishActivity(publication);
- },
- );
- }
-
async function listPrincipals(endpointId: string) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
diff --git a/server/src/services/chat-channels/github/resources.ts b/server/src/services/chat-channels/github/resources.ts
new file mode 100644
index 00000000000..5abe2384cd2
--- /dev/null
+++ b/server/src/services/chat-channels/github/resources.ts
@@ -0,0 +1,278 @@
+import { and, asc, eq, sql } from "drizzle-orm";
+import { chatConversations, chatEndpointResources, chatEndpoints, type Db } from "@paperclipai/db";
+import { conflict, notFound } from "../../../errors.js";
+import type { CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
+import type { ChatProviderResourceInventoryItem } from "../../chat-provider-inventory.js";
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+type DbTransaction = Parameters[0]>[0];
+
+export function githubRepositoryInventoryItemFromPayload(
+ payload: unknown,
+): ChatProviderResourceInventoryItem | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
+ return null;
+ const repository = (payload as { repository?: unknown }).repository;
+ if (
+ !repository ||
+ typeof repository !== "object" ||
+ Array.isArray(repository)
+ )
+ return null;
+ const value = repository as {
+ id?: unknown;
+ full_name?: unknown;
+ html_url?: unknown;
+ name?: unknown;
+ owner?: { id?: unknown; login?: unknown };
+ private?: unknown;
+ };
+ const repositoryId =
+ typeof value.id === "number" && Number.isSafeInteger(value.id)
+ ? String(value.id)
+ : typeof value.id === "string" && /^\d+$/.test(value.id)
+ ? value.id
+ : null;
+ const owner =
+ typeof value.owner?.login === "string" ? value.owner.login.trim() : "";
+ const name = typeof value.name === "string" ? value.name.trim() : "";
+ const fullName =
+ typeof value.full_name === "string" && value.full_name.includes("/")
+ ? value.full_name.trim()
+ : owner && name
+ ? `${owner}/${name}`
+ : "";
+ if (!repositoryId || !fullName) return null;
+ const ownerId =
+ typeof value.owner?.id === "number" && Number.isSafeInteger(value.owner.id)
+ ? String(value.owner.id)
+ : typeof value.owner?.id === "string" && /^\d+$/.test(value.owner.id)
+ ? value.owner.id
+ : undefined;
+ return {
+ providerResourceId: fullName.toLowerCase(),
+ parentProviderResourceId: ownerId,
+ type: "repository",
+ label: fullName,
+ providerUrl:
+ typeof value.html_url === "string" && value.html_url.length > 0
+ ? value.html_url
+ : `https://github.com/${fullName}`,
+ metadata: {
+ providerRepositoryId: repositoryId,
+ fullName,
+ ...(owner ? { owner } : {}),
+ private: value.private === true,
+ source: "provider_webhook",
+ },
+ };
+}
+
+function githubRepositoryStableId(
+ item: ChatProviderResourceInventoryItem,
+): string | null {
+ const value = item.metadata?.providerRepositoryId;
+ return typeof value === "string" && /^\d+$/.test(value) ? value : null;
+}
+
+export async function reconcileGitHubRepositoryCoordinates(tx: DbTransaction, endpoint: EndpointRow, item: ChatProviderResourceInventoryItem) {
+ const stableGitHubId =
+ githubRepositoryStableId(item);
+ if (stableGitHubId) {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`chat-github-repository:${endpoint.id}:${stableGitHubId}`}, 0))`,
+ );
+ const stableResource = await tx
+ .select()
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpoint.id),
+ eq(chatEndpointResources.type, "repository"),
+ sql`${chatEndpointResources.metadata}->>'providerRepositoryId' = ${stableGitHubId}`,
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (
+ stableResource &&
+ stableResource.providerResourceId !== item.providerResourceId
+ ) {
+ const coordinateResource = await tx
+ .select()
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpoint.id),
+ eq(chatEndpointResources.type, "repository"),
+ eq(
+ chatEndpointResources.providerResourceId,
+ item.providerResourceId,
+ ),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (coordinateResource && coordinateResource.id !== stableResource.id) {
+ const coordinateConversation = await tx
+ .select({ id: chatConversations.id })
+ .from(chatConversations)
+ .where(
+ and(
+ eq(chatConversations.endpointId, endpoint.id),
+ eq(chatConversations.resourceId, coordinateResource.id),
+ ),
+ )
+ .limit(1)
+ .then((rows) => rows[0] ?? null);
+ if (coordinateConversation) {
+ throw conflict(
+ "GitHub repository identity is already bound to two coordinates",
+ { code: "chat_github_repository_identity_conflict" },
+ );
+ }
+ await tx
+ .delete(chatEndpointResources)
+ .where(eq(chatEndpointResources.id, coordinateResource.id));
+ }
+
+ const oldRepository = stableResource.providerResourceId;
+ const oldThreadPrefix = `github:${oldRepository}`;
+ const newThreadPrefix = `github:${item.providerResourceId}`;
+ const oldProviderUrl = `https://github.com/${oldRepository}`;
+ const newProviderUrl = `https://github.com/${item.providerResourceId}`;
+ await tx
+ .update(chatEndpointResources)
+ .set({
+ providerResourceId: item.providerResourceId,
+ parentProviderResourceId: item.parentProviderResourceId ?? null,
+ label: item.label,
+ providerUrl: item.providerUrl ?? null,
+ availability: "available",
+ enabled:
+ stableResource.enabled || coordinateResource?.enabled === true,
+ metadata: item.metadata ?? {},
+ updatedAt: new Date(),
+ })
+ .where(eq(chatEndpointResources.id, stableResource.id));
+ await tx
+ .update(chatConversations)
+ .set({
+ externalConversationId: sql`case
+ when lower(${chatConversations.externalConversationId}) = ${oldRepository} then ${item.providerResourceId}
+ when lower(${chatConversations.externalConversationId}) = ${oldThreadPrefix} then ${newThreadPrefix}
+ else ${chatConversations.externalConversationId}
+ end`,
+ externalThreadId: sql`case
+ when lower(${chatConversations.externalThreadId}) = ${oldThreadPrefix} then ${newThreadPrefix}
+ when lower(${chatConversations.externalThreadId}) like ${`${oldThreadPrefix}:%`}
+ then ${newThreadPrefix} || substring(
+ ${chatConversations.externalThreadId}
+ from char_length(${oldThreadPrefix}) + 1
+ )
+ else ${chatConversations.externalThreadId}
+ end`,
+ externalLabel: item.label,
+ providerUrl: sql`case
+ when lower(${chatConversations.providerUrl}) like ${`${oldProviderUrl}/%`}
+ then ${newProviderUrl} || substring(
+ ${chatConversations.providerUrl}
+ from char_length(${oldProviderUrl}) + 1
+ )
+ else ${chatConversations.providerUrl}
+ end`,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(chatConversations.companyId, endpoint.companyId),
+ eq(chatConversations.endpointId, endpoint.id),
+ eq(chatConversations.resourceId, stableResource.id),
+ ),
+ );
+ return { id: stableResource.id };
+ }
+ }
+
+ return null;
+}
+
+type GitHubResourceOptions = {
+ endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
+ upsertProviderResourceRow: (tx: DbTransaction, endpoint: EndpointRow, item: ChatProviderResourceInventoryItem) => Promise<{ id: string } | null>;
+ updateResourceSelection: (endpointId: string, updates: Array<{ id: string; enabled: boolean }>, actorUserId?: string | null, options?: { allGitHubRepositoriesEnabled?: boolean }) => Promise;
+};
+
+export function createGitHubResources(db: Db, options: GitHubResourceOptions) {
+ const { endpointRecord, upsertProviderResourceRow, updateResourceSelection } = options;
+ async function reconcileGitHubWebhookRepository(
+ endpoint: EndpointRow,
+ payload: unknown,
+ credentialLease: CredentialMutationLeaseGuard,
+ ) {
+ const item = githubRepositoryInventoryItemFromPayload(payload);
+ if (!item) return;
+ await db.transaction(async (tx) => {
+ await credentialLease.assertOwned(tx);
+ const resource = await upsertProviderResourceRow(tx, endpoint, item);
+ if (!resource) return;
+ // A correctly signed repository callback is current provider proof. Keep
+ // Paperclip's enabled choice, and reopen only conversations previously
+ // quarantined for provider availability loss.
+ await tx
+ .update(chatConversations)
+ .set({ state: "active", updatedAt: new Date() })
+ .where(
+ and(
+ eq(chatConversations.companyId, endpoint.companyId),
+ eq(chatConversations.endpointId, endpoint.id),
+ eq(chatConversations.resourceId, resource.id),
+ eq(chatConversations.state, "unavailable"),
+ ),
+ );
+ await credentialLease.assertOwned(tx);
+ });
+ }
+
+ async function listGitHubRepositories(
+ endpointId: string,
+ query: { limit: number; offset: number; search: string },
+ ) {
+ const record = await endpointRecord(endpointId);
+ if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
+ const scope = and(
+ eq(chatEndpointResources.companyId, record.endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpointId),
+ eq(chatEndpointResources.type, "repository"),
+ );
+ // Literal, case-insensitive substring search (%, _ and backslashes are
+ // ordinary characters). Counts always describe the entire connection.
+ const search = query.search
+ ? sql`strpos(lower(${chatEndpointResources.label}), lower(${query.search})) > 0`
+ : undefined;
+ const [rows, [counts]] = await Promise.all([
+ db.select().from(chatEndpointResources).where(and(scope, search))
+ .orderBy(asc(chatEndpointResources.label), asc(chatEndpointResources.id))
+ .offset(query.offset).limit(query.limit + 1),
+ db.select({
+ totalCount: sql`count(*)::integer`,
+ enabledCount: sql`count(*) filter (where ${chatEndpointResources.enabled})::integer`,
+ availableCount: sql`count(*) filter (where ${chatEndpointResources.availability} = 'available')::integer`,
+ }).from(chatEndpointResources).where(scope),
+ ]);
+ return {
+ items: rows.slice(0, query.limit),
+ nextOffset: rows.length > query.limit ? query.offset + query.limit : null,
+ totalCount: counts!.totalCount,
+ enabledCount: counts!.enabledCount,
+ availableCount: counts!.availableCount,
+ };
+ }
+
+ async function toggleAllGitHubRepositories(endpointId: string, enabled: boolean, actorUserId?: string | null) {
+ await updateResourceSelection(endpointId, [], actorUserId, { allGitHubRepositoriesEnabled: enabled });
+ return { success: true as const };
+ }
+ return { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories };
+}
diff --git a/server/src/services/chat-channels/imessage-photon/resources.ts b/server/src/services/chat-channels/imessage-photon/resources.ts
new file mode 100644
index 00000000000..b86a15c3e42
--- /dev/null
+++ b/server/src/services/chat-channels/imessage-photon/resources.ts
@@ -0,0 +1,14 @@
+import type { chatEndpointResources } from "@paperclipai/db";
+
+export function imessageResourceParticipants(resource: typeof chatEndpointResources.$inferSelect) {
+ return resource.providerResourceId.startsWith("imessage-photon:") &&
+ Array.isArray(resource.metadata.participants)
+ ? resource.metadata.participants.flatMap((participant) => {
+ const address =
+ participant && typeof participant === "object"
+ ? (participant as Record).address
+ : null;
+ return typeof address === "string" ? [address] : [];
+ })
+ : undefined;
+}
diff --git a/server/src/services/chat-channels/microsoft-teams/resources.ts b/server/src/services/chat-channels/microsoft-teams/resources.ts
new file mode 100644
index 00000000000..675245b00e5
--- /dev/null
+++ b/server/src/services/chat-channels/microsoft-teams/resources.ts
@@ -0,0 +1,3 @@
+export function baseTeamsConversationId(value: string): string {
+ return value.replace(/;messageid=[^;]+/i, "");
+}
diff --git a/server/src/services/chat-channels/resource-reconciliation.ts b/server/src/services/chat-channels/resource-reconciliation.ts
new file mode 100644
index 00000000000..ac0d1069d73
--- /dev/null
+++ b/server/src/services/chat-channels/resource-reconciliation.ts
@@ -0,0 +1,598 @@
+import { imessageResourceParticipants } from "./imessage-photon/resources.js";
+import { createGitHubResources, reconcileGitHubRepositoryCoordinates } from "./github/resources.js";
+import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
+import { slackResourceLabelIsFallback } from "./slack/resources.js";
+import { telegramResourceLabelIsFallback, prepareTelegramResourceMigration, migrateTelegramResourceConversations } from "./telegram/resources.js";
+import { and, asc, eq, inArray, ne, sql } from "drizzle-orm";
+import { chatConversations, chatDeliveries, chatEndpointResources, chatEndpoints, toolConnections, type Db } from "@paperclipai/db";
+import type { ChatEndpointSetupState } from "@paperclipai/shared";
+import { badRequest, conflict, notFound, unprocessable } from "../../errors.js";
+import { redactSensitiveText } from "../../redaction.js";
+import { notifyChatDeliveryWork, notifyChatEndpointWork } from "../chat-work-notifications.js";
+import { logActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
+import type { CredentialMutationLeaseGuard, chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
+import type { ChatProviderInventoryResult, ChatProviderResourceInventoryItem } from "../chat-provider-inventory.js";
+import type { ChatProviderLifecycleEffect } from "../chat-provider-lifecycle.js";
+
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+type DbTransaction = Parameters[0]>[0];
+type InternalSetupState = ChatEndpointSetupState & { runtimeGeneration?: number };
+
+type ResourceReconciliationOptions = {
+ endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
+ withCredentialMutationLease: ReturnType;
+ runtimeGeneration: (setup: ChatEndpointSetupState) => number;
+ maxErrorText: number;
+};
+
+export function createChatResourceReconciliation(db: Db, options: ResourceReconciliationOptions) {
+ const { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT } = options;
+
+ async function upsertProviderResourceRow(
+ tx: DbTransaction,
+ endpoint: EndpointRow,
+ item: ChatProviderResourceInventoryItem,
+ ) {
+ if (endpoint.provider === "github") {
+ const renamed = await reconcileGitHubRepositoryCoordinates(tx, endpoint, item);
+ if (renamed) return renamed;
+ }
+
+ const [resource] = await tx
+ .insert(chatEndpointResources)
+ .values({
+ companyId: endpoint.companyId,
+ endpointId: endpoint.id,
+ type: item.type,
+ providerResourceId: item.providerResourceId,
+ parentProviderResourceId: item.parentProviderResourceId ?? null,
+ label: item.label,
+ providerUrl: item.providerUrl ?? null,
+ availability: "available",
+ // Slack inventory contains only channels the bot has joined. Apply the
+ // invitation default on insert; conflict updates preserve operator choices.
+ enabled:
+ endpoint.provider === "slack" &&
+ item.type === "channel" &&
+ item.metadata?.creator !== endpoint.botExternalId,
+ metadata: item.metadata ?? {},
+ })
+ .onConflictDoUpdate({
+ target: [
+ chatEndpointResources.endpointId,
+ chatEndpointResources.type,
+ chatEndpointResources.providerResourceId,
+ ],
+ set: {
+ parentProviderResourceId: item.parentProviderResourceId ?? null,
+ label: item.label,
+ providerUrl: item.providerUrl ?? null,
+ availability: "available",
+ metadata: item.metadata ?? {},
+ updatedAt: new Date(),
+ },
+ })
+ .returning({ id: chatEndpointResources.id });
+ return resource ?? null;
+ }
+
+ async function reconcileProviderResourceRows(
+ endpoint: EndpointRow,
+ inventory: ChatProviderInventoryResult,
+ credentialLease: CredentialMutationLeaseGuard,
+ ) {
+ const resourceType =
+ endpoint.provider === "github" ? "repository" : "channel";
+ const discovered = new Set(
+ inventory.resources.map((resource) => resource.providerResourceId),
+ );
+ const absentAvailability =
+ endpoint.provider === "github" ? "removed" : "unavailable";
+ await db.transaction(async (tx) => {
+ await credentialLease.assertOwned(tx);
+ for (const item of inventory.resources) {
+ const resource = await upsertProviderResourceRow(tx, endpoint, item);
+ if (resource) {
+ // Resource recovery reopens only bindings that Paperclip marked
+ // unavailable. Completed historical tasks stay completed.
+ await tx
+ .update(chatConversations)
+ .set({ state: "active", updatedAt: new Date() })
+ .where(
+ and(
+ eq(chatConversations.companyId, endpoint.companyId),
+ eq(chatConversations.endpointId, endpoint.id),
+ eq(chatConversations.resourceId, resource.id),
+ eq(chatConversations.state, "unavailable"),
+ ),
+ );
+ }
+ }
+
+ const existing = await tx
+ .select({
+ id: chatEndpointResources.id,
+ providerResourceId: chatEndpointResources.providerResourceId,
+ })
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpoint.id),
+ eq(chatEndpointResources.type, resourceType),
+ ),
+ );
+ for (const resource of existing) {
+ if (discovered.has(resource.providerResourceId)) continue;
+ await tx
+ .update(chatEndpointResources)
+ .set({ availability: absentAvailability, updatedAt: new Date() })
+ .where(eq(chatEndpointResources.id, resource.id));
+ await tx
+ .update(chatConversations)
+ .set({ state: "unavailable", updatedAt: new Date() })
+ .where(
+ and(
+ eq(chatConversations.companyId, endpoint.companyId),
+ eq(chatConversations.endpointId, endpoint.id),
+ eq(chatConversations.resourceId, resource.id),
+ inArray(chatConversations.state, ["active", "waiting"]),
+ ),
+ );
+ }
+ await credentialLease.assertOwned(tx);
+ });
+ }
+
+ async function listResources(endpointId: string) {
+ const resources = await db
+ .select()
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.endpointId, endpointId),
+ ne(chatEndpointResources.type, "direct_message"),
+ ),
+ )
+ .orderBy(asc(chatEndpointResources.label));
+ return resources.map((resource) => ({
+ ...resource,
+ participants: imessageResourceParticipants(resource),
+ }));
+ }
+
+ async function replaceResources(
+ endpointId: string,
+ updates: Array<{ id: string; enabled: boolean }>,
+ actorUserId?: string | null,
+ options?: { initialGitHubImport?: boolean },
+ ) {
+ await updateResourceSelection(endpointId, updates, actorUserId, options);
+ return listResources(endpointId);
+ }
+
+ async function updateResourceSelection(
+ endpointId: string,
+ updates: Array<{ id: string; enabled: boolean }>,
+ actorUserId?: string | null,
+ options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
+ ) {
+ const initial = await endpointRecord(endpointId);
+ if (!initial) throw notFound("Chat endpoint not found");
+ const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
+ if (bulk && initial.endpoint.provider !== "github") throw badRequest("Only GitHub repositories support toggle all");
+ if (updates.length === 0 && initial.endpoint.provider !== "github") return;
+ if (initial.endpoint.provider === "imessage-photon" && initial.endpoint.botExternalId?.startsWith("photon-project:") && updates.some((entry) => entry.enabled))
+ throw unprocessable("Photon shared channels support direct messages only; groups cannot be enabled");
+ await withCredentialMutationLease(
+ initial.endpoint,
+ async (credentialLease) => {
+ const ids = updates.map((entry) => entry.id);
+ const publications: ActivityPublication[] = [];
+ await db.transaction(async (tx) => {
+ await credentialLease.assertOwned(tx);
+ const [endpoint] = await tx
+ .select({
+ companyId: chatEndpoints.companyId,
+ connectionId: chatEndpoints.connectionId,
+ provider: chatEndpoints.provider,
+ setup: chatEndpoints.setup,
+ })
+ .from(chatEndpoints)
+ .where(
+ and(
+ eq(chatEndpoints.id, endpointId),
+ eq(chatEndpoints.companyId, initial.endpoint.companyId),
+ ),
+ )
+ .for("no key update");
+ if (!endpoint) throw notFound("Chat endpoint not found");
+ if (options?.initialGitHubImport && endpoint.provider === "github" && (endpoint.setup.github?.repositorySelectionSaved || !endpoint.setup.github?.initialRepositoryImportPending)) return;
+ const rows = await tx
+ .select({
+ id: chatEndpointResources.id,
+ availability: chatEndpointResources.availability,
+ enabled: chatEndpointResources.enabled,
+ })
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpointId),
+ bulk
+ ? and(
+ eq(chatEndpointResources.type, "repository"),
+ options!.allGitHubRepositoriesEnabled
+ ? eq(chatEndpointResources.availability, "available")
+ : undefined,
+ )
+ : inArray(chatEndpointResources.id, ids),
+ ),
+ )
+ .orderBy(asc(chatEndpointResources.id))
+ .for("no key update");
+ if (!bulk && rows.length !== new Set(ids).size)
+ throw unprocessable("Every resource must belong to this endpoint");
+ const selectedUpdates = bulk
+ ? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! }))
+ : updates;
+ const availabilityById = new Map(
+ rows.map((row) => [row.id, row.availability]),
+ );
+ // Validate every submitted grant, including intermediate duplicate
+ // entries. Netting below describes the audit, not new authority.
+ const unavailable = selectedUpdates.find(
+ (entry) =>
+ entry.enabled && availabilityById.get(entry.id) !== "available",
+ );
+ if (unavailable)
+ throw conflict(
+ "A destination must still be available from the provider before it can be enabled",
+ { code: "chat_resource_unavailable", resourceId: unavailable.id },
+ );
+ const finalEnabled = new Map(
+ selectedUpdates.map((entry) => [entry.id, entry.enabled]),
+ );
+ const changes = rows
+ .filter((row) => row.enabled !== finalEnabled.get(row.id))
+ .map((row) => ({
+ resourceId: row.id,
+ before: { enabled: row.enabled },
+ after: { enabled: finalEnabled.get(row.id)! },
+ }));
+ if (bulk) {
+ // One atomic database update, independent of pagination/search and
+ // serialized with refresh, reconnect, and individual row changes.
+ await tx.update(chatEndpointResources)
+ .set({ enabled: options!.allGitHubRepositoriesEnabled!, updatedAt: new Date() })
+ .where(and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpointId),
+ eq(chatEndpointResources.type, "repository"),
+ options!.allGitHubRepositoriesEnabled
+ ? eq(chatEndpointResources.availability, "available")
+ : undefined,
+ ));
+ } else for (const entry of selectedUpdates)
+ await tx
+ .update(chatEndpointResources)
+ .set({ enabled: entry.enabled, updatedAt: new Date() })
+ .where(
+ and(
+ eq(chatEndpointResources.companyId, endpoint.companyId),
+ eq(chatEndpointResources.endpointId, endpointId),
+ eq(chatEndpointResources.id, entry.id),
+ ),
+ );
+ if (endpoint.provider === "github")
+ await tx.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || '{"repositorySelectionSaved":true,"initialRepositoryImportPending":false}'::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
+ if (changes.length > 0 || (endpoint.provider === "github" && !endpoint.setup.github?.repositorySelectionSaved))
+ await logActivity(
+ tx as unknown as Db,
+ {
+ companyId: endpoint.companyId,
+ actorType: "user",
+ actorId: actorUserId ?? "board",
+ action: "chat_endpoint.resources_updated",
+ entityType: "tool_connection",
+ entityId: endpoint.connectionId,
+ details: { endpointId, provider: endpoint.provider, changes },
+ },
+ publications,
+ );
+ await credentialLease.assertOwned(tx);
+ });
+ // A committed reach change is factual even if the outer lease's last
+ // check subsequently fails. Never publish an uncommitted activity.
+ for (const publication of publications) publishActivity(publication);
+ },
+ );
+ }
+
+ async function hasNewerProcessedProviderLifecycleEffect(
+ endpoint: EndpointRow,
+ effect: Extract,
+ ): Promise {
+ const sequence = effect.providerOrder?.sequence;
+ const occurredAt = effect.providerOrder?.occurredAt;
+ if (!sequence && !occurredAt) return false;
+ const providerResourceId =
+ endpoint.provider === "microsoft-teams"
+ ? baseTeamsConversationId(effect.providerResourceId)
+ : effect.providerResourceId;
+ const newerOrder = sequence
+ ? sql`(
+ ${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}' ~ '^[0-9]+([.][0-9]+)?$'
+ and (${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}')::numeric > cast(${sequence} as numeric)
+ )`
+ : sql`(
+ nullif(${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,occurredAt}', '')::timestamptz > cast(${occurredAt!} as timestamptz)
+ )`;
+ const newer = await db
+ .select({ id: chatDeliveries.id })
+ .from(chatDeliveries)
+ .where(
+ and(
+ eq(chatDeliveries.endpointId, endpoint.id),
+ eq(chatDeliveries.state, "processed"),
+ inArray(chatDeliveries.eventKind, ["installation", "uninstallation"]),
+ sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,kind}' = 'resource'`,
+ sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,provider}' = ${effect.provider}`,
+ sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerResourceId}' = ${providerResourceId}`,
+ newerOrder,
+ ),
+ )
+ .limit(1);
+ return newer.length > 0;
+ }
+
+ async function applyProviderAvailability(input: {
+ currentEndpoint: EndpointRow;
+ effect: ChatProviderLifecycleEffect;
+ credentialLease: CredentialMutationLeaseGuard;
+ staleResourceEffect: boolean;
+ claimed: Pick;
+ candidate: Pick;
+ }): Promise {
+ const { currentEndpoint, effect, credentialLease, staleResourceEffect, claimed, candidate } = input;
+ await db.transaction(async (tx) => {
+ await credentialLease.assertOwned(tx);
+ const now = new Date();
+ if (staleResourceEffect) {
+ // Persisted provider-native ordering wins over request arrival or
+ // lease acquisition order. The older callback remains auditable,
+ // but cannot roll a resource or conversation back.
+ await tx
+ .update(chatEndpoints)
+ .set({ lastEventAt: now, updatedAt: now })
+ .where(eq(chatEndpoints.id, currentEndpoint.id));
+ await notifyChatDeliveryWork(tx);
+ await tx
+ .update(chatDeliveries)
+ .set({
+ state: "processed",
+ processedAt: now,
+ redactedError: null,
+ nextAttemptAt: null,
+ updatedAt: now,
+ })
+ .where(eq(chatDeliveries.id, claimed.id));
+ return;
+ }
+ if (effect.kind === "resource") {
+ const providerResourceId =
+ currentEndpoint.provider === "microsoft-teams"
+ ? baseTeamsConversationId(effect.providerResourceId)
+ : effect.providerResourceId;
+ const previousProviderResourceId =
+ currentEndpoint.provider === "telegram"
+ ? effect.previousProviderResourceId
+ : undefined;
+ const { previousResource, migratedLabel, migratedEnabled } = await prepareTelegramResourceMigration(
+ tx, currentEndpoint, effect, providerResourceId, previousProviderResourceId, now,
+ );
+ const preserveProviderLabel =
+ (currentEndpoint.provider === "slack" &&
+ slackResourceLabelIsFallback(
+ providerResourceId,
+ migratedLabel,
+ )) ||
+ (currentEndpoint.provider === "telegram" &&
+ telegramResourceLabelIsFallback(
+ providerResourceId,
+ migratedLabel,
+ ));
+ const [resource] = await tx
+ .insert(chatEndpointResources)
+ .values({
+ companyId: currentEndpoint.companyId,
+ endpointId: currentEndpoint.id,
+ type: effect.resourceType,
+ providerResourceId,
+ parentProviderResourceId:
+ effect.parentProviderResourceId ?? null,
+ label: migratedLabel,
+ providerUrl: effect.providerUrl ?? null,
+ availability: effect.availability,
+ enabled:
+ migratedEnabled ||
+ (currentEndpoint.provider === "slack" &&
+ effect.resourceType === "channel" &&
+ effect.availability === "available"),
+ metadata: effect.metadata ?? {},
+ })
+ .onConflictDoUpdate({
+ target: [
+ chatEndpointResources.endpointId,
+ chatEndpointResources.type,
+ chatEndpointResources.providerResourceId,
+ ],
+ set: {
+ parentProviderResourceId:
+ effect.parentProviderResourceId ?? null,
+ ...(preserveProviderLabel ? {} : { label: migratedLabel }),
+ providerUrl: effect.providerUrl ?? null,
+ availability: effect.availability,
+ ...(previousProviderResourceId
+ ? {
+ enabled: sql`${chatEndpointResources.enabled} or ${migratedEnabled}`,
+ }
+ : {}),
+ metadata: effect.metadata ?? {},
+ updatedAt: now,
+ },
+ })
+ .returning({ id: chatEndpointResources.id });
+ if (
+ resource &&
+ previousResource &&
+ previousProviderResourceId !== providerResourceId
+ ) {
+ await migrateTelegramResourceConversations(tx, currentEndpoint, previousResource, resource, previousProviderResourceId, providerResourceId, migratedLabel, now);
+ }
+ if (resource) {
+ await tx
+ .update(chatConversations)
+ .set({
+ state:
+ effect.availability === "available"
+ ? "active"
+ : "unavailable",
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(chatConversations.companyId, currentEndpoint.companyId),
+ eq(chatConversations.endpointId, currentEndpoint.id),
+ eq(chatConversations.resourceId, resource.id),
+ effect.availability === "available"
+ ? eq(chatConversations.state, "unavailable")
+ : inArray(chatConversations.state, ["active", "waiting"]),
+ ),
+ );
+ }
+ await tx
+ .update(chatEndpoints)
+ .set({ lastEventAt: now, updatedAt: now })
+ .where(eq(chatEndpoints.id, currentEndpoint.id));
+ } else {
+ const reason = redactSensitiveText(effect.reason).slice(
+ 0,
+ MAX_ERROR_TEXT,
+ );
+ if (effect.availability === "available") {
+ if (
+ currentEndpoint.status !== "paused" &&
+ currentEndpoint.status !== "archived"
+ ) {
+ const status =
+ currentEndpoint.setup.step === "complete"
+ ? "active"
+ : currentEndpoint.status === "attention"
+ ? "verifying"
+ : currentEndpoint.status;
+ if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx);
+ await tx
+ .update(chatEndpoints)
+ .set({
+ status,
+ healthMessage:
+ status === "active"
+ ? "Connected"
+ : "Waiting for a test conversation",
+ lastError: null,
+ lastEventAt: now,
+ updatedAt: now,
+ })
+ .where(eq(chatEndpoints.id, currentEndpoint.id));
+ await tx
+ .update(toolConnections)
+ .set({
+ status: "active",
+ enabled: true,
+ healthStatus: "healthy",
+ healthMessage: "Connected",
+ lastError: null,
+ healthCheckedAt: now,
+ updatedAt: now,
+ })
+ .where(eq(toolConnections.id, currentEndpoint.connectionId));
+ }
+ } else {
+ await tx
+ .update(chatEndpoints)
+ .set({
+ status: effect.availability,
+ healthMessage: reason,
+ lastError: reason,
+ lastEventAt: now,
+ setup: {
+ ...currentEndpoint.setup,
+ runtimeGeneration:
+ runtimeGeneration(currentEndpoint.setup) + 1,
+ } as InternalSetupState,
+ updatedAt: now,
+ })
+ .where(eq(chatEndpoints.id, currentEndpoint.id));
+ await tx
+ .update(toolConnections)
+ .set({
+ status: "disabled",
+ enabled: false,
+ healthStatus:
+ effect.availability === "revoked" ? "failed" : "degraded",
+ healthMessage: reason,
+ lastError: reason,
+ healthCheckedAt: now,
+ updatedAt: now,
+ })
+ .where(eq(toolConnections.id, currentEndpoint.connectionId));
+ await tx
+ .update(chatEndpointResources)
+ .set({ availability: "unavailable", updatedAt: now })
+ .where(
+ and(
+ eq(
+ chatEndpointResources.companyId,
+ currentEndpoint.companyId,
+ ),
+ eq(chatEndpointResources.endpointId, currentEndpoint.id),
+ ),
+ );
+ await tx
+ .update(chatConversations)
+ .set({ state: "unavailable", updatedAt: now })
+ .where(
+ and(
+ eq(chatConversations.companyId, currentEndpoint.companyId),
+ eq(chatConversations.endpointId, currentEndpoint.id),
+ inArray(chatConversations.state, ["active", "waiting"]),
+ ),
+ );
+ }
+ }
+ await notifyChatDeliveryWork(tx);
+ await tx
+ .update(chatDeliveries)
+ .set({
+ state: "processed",
+ processedAt: now,
+ redactedError: null,
+ nextAttemptAt: null,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(chatDeliveries.id, claimed.id),
+ eq(chatDeliveries.state, "processing"),
+ eq(chatDeliveries.attempts, candidate.attempts + 1),
+ ),
+ );
+ await credentialLease.assertOwned(tx);
+ });
+ }
+
+ const { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories } = createGitHubResources(db, { endpointRecord, upsertProviderResourceRow, updateResourceSelection });
+
+ return { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability };
+}
diff --git a/server/src/services/chat-channels/slack/resources.ts b/server/src/services/chat-channels/slack/resources.ts
new file mode 100644
index 00000000000..1d7773f750a
--- /dev/null
+++ b/server/src/services/chat-channels/slack/resources.ts
@@ -0,0 +1,10 @@
+export function slackResourceLabelIsFallback(
+ providerResourceId: string,
+ label: string,
+): boolean {
+ const candidate = label.trim();
+ return (
+ candidate === providerResourceId ||
+ candidate === `slack:${providerResourceId}`
+ );
+}
diff --git a/server/src/services/chat-channels/telegram/resources.ts b/server/src/services/chat-channels/telegram/resources.ts
new file mode 100644
index 00000000000..17bc3b39b52
--- /dev/null
+++ b/server/src/services/chat-channels/telegram/resources.ts
@@ -0,0 +1,104 @@
+import { and, eq, sql } from "drizzle-orm";
+import { chatConversations, chatEndpointResources, chatEndpoints, type Db } from "@paperclipai/db";
+import type { ChatProviderLifecycleEffect } from "../../chat-provider-lifecycle.js";
+type DbTransaction = Parameters[0]>[0];
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+
+export function telegramResourceLabelIsFallback(
+ providerResourceId: string,
+ label: string,
+): boolean {
+ const candidate = label.trim();
+ return (
+ candidate === providerResourceId ||
+ candidate === `telegram:${providerResourceId}`
+ );
+}
+
+export async function prepareTelegramResourceMigration(
+ tx: DbTransaction, currentEndpoint: EndpointRow,
+ effect: Extract,
+ providerResourceId: string, previousProviderResourceId: string | undefined, now: Date,
+) {
+ const previousResource = previousProviderResourceId
+ ? await tx
+ .select()
+ .from(chatEndpointResources)
+ .where(
+ and(
+ eq(chatEndpointResources.endpointId, currentEndpoint.id),
+ eq(chatEndpointResources.type, "chat"),
+ eq(
+ chatEndpointResources.providerResourceId,
+ previousProviderResourceId,
+ ),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null)
+ : null;
+ const migratedLabel =
+ previousResource &&
+ telegramResourceLabelIsFallback(providerResourceId, effect.label)
+ ? previousResource.label
+ : effect.label;
+ const migratedEnabled = previousResource?.enabled === true;
+ if (
+ previousResource &&
+ previousProviderResourceId !== providerResourceId
+ ) {
+ await tx
+ .update(chatEndpointResources)
+ .set({
+ availability: "unavailable",
+ enabled: false,
+ metadata: {
+ ...previousResource.metadata,
+ source: "chat_migration",
+ migratedTo: providerResourceId,
+ },
+ updatedAt: now,
+ })
+ .where(eq(chatEndpointResources.id, previousResource.id));
+ }
+ return { previousResource, migratedLabel, migratedEnabled };
+}
+
+export async function migrateTelegramResourceConversations(
+ tx: DbTransaction, currentEndpoint: EndpointRow,
+ previousResource: typeof chatEndpointResources.$inferSelect,
+ resource: { id: string }, previousProviderResourceId: string | undefined,
+ providerResourceId: string, migratedLabel: string, now: Date,
+) {
+ const previousThreadPrefix = `telegram:${previousProviderResourceId}`;
+ const migratedThreadPrefix = `telegram:${providerResourceId}`;
+ await tx
+ .update(chatConversations)
+ .set({
+ resourceId: resource.id,
+ // Telegram SDK threads use the namespaced channel id. Keep
+ // the migrated row in that canonical shape so the first
+ // reply from the replacement supergroup finds this same
+ // task instead of creating a second conversation.
+ externalConversationId: migratedThreadPrefix,
+ externalThreadId: sql`case
+ when ${chatConversations.externalThreadId} = ${previousThreadPrefix} then ${migratedThreadPrefix}
+ when ${chatConversations.externalThreadId} like ${`${previousThreadPrefix}:%`}
+ then ${migratedThreadPrefix} || substring(
+ ${chatConversations.externalThreadId}
+ from char_length(${previousThreadPrefix}) + 1
+ )
+ else ${chatConversations.externalThreadId}
+ end`,
+ externalLabel: migratedLabel,
+ state: "active",
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(chatConversations.companyId, currentEndpoint.companyId),
+ eq(chatConversations.endpointId, currentEndpoint.id),
+ eq(chatConversations.resourceId, previousResource.id),
+ ),
+ );
+}
From 848e659bc666b18fc05c990c0de8758dce901d67 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:40:09 -0500
Subject: [PATCH 06/23] refactor(heartbeat): extract session preparation
(#15966)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat execution prepares a provider session before dispatch.
> - The same executor also owns workspace setup, admission, cleanup, and
recovery.
> - Session freshness and compaction form a separate responsibility with
explicit inputs and results.
> - This pull request moves those two session phases into one module and
keeps their order around workspace preparation.
> - The benefit is a shorter executor with unchanged session behavior.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Maintenance of heartbeat execution. This continues the extraction series
after #15872.
**Current behavior**
`heartbeat.ts` has 4,989 lines and 206,650 bytes. Session config
fingerprints, resume selection, credential compatibility, and compaction
are embedded in the executor.
**Proposed behavior**
The executor calls `prepareHeartbeatSession` before workspace
preparation and `resolveHeartbeatAdapterSession` after it.
`heartbeat.ts` has 4,740 lines and 195,258 bytes. The new cohesive
module has 385 lines and 19,518 bytes.
**Reason and benefit**
A reviewer can find session policy in one module. Runtime acquisition,
admission gates, and cleanup remain in the executor. Related open work
includes #15787 and #15142; this PR does not change either policy.
**Breaking changes**
None intended. Existing public helper exports remain available.
## What Changed
- Move 267 lines into
`server/src/services/heartbeat/session-preparation.ts` with typed task,
config, workspace, and session inputs.
- Keep config fingerprints and context mutations on the same objects.
Return compaction and adapter-session results to existing consumers.
- Add four real-PostgreSQL characterization tests through heartbeat
wakeup and adapter dispatch.
- Document the two session preparation phases.
## Verification
- Before extraction: four new characterization tests pass; 180 existing
workspace/session tests pass.
- After extraction: 205 tests pass across session preparation,
workspace/session policy, managed-account session compatibility, and
accepted-plan refresh. No skips.
- `pnpm -r typecheck` and `pnpm build` pass.
- Normalized syntax comparison: both moved blocks and the reconstructed
executor, including public exports, match the base.
- Isolated browser acceptance: 2/2 pass. A real process worker completes
after automatic recovery; persistent timeouts stop after three attempts.
Both terminal states survive reload.
- All hosted CI gates pass on `d33f8506a7`, including the complete test
shards, typecheck, build, and browser suites. The duplicate local `pnpm
test:run` was interrupted after hosted CI passed; it is not claimed as a
full local pass.
- Greptile: 5/5 on this head, with no review threads.
## Risks
- Session freshness can alter provider context if a dependency is
omitted. Typed inputs and passing-before/passing-after dispatch tests
cover unchanged resumes, model changes, tool refresh, and compaction.
- The module acquires no runtime resource. Workspace preparation still
occurs between the two phases.
- Live external providers are not exercised; browser acceptance uses a
real process fixture in a disposable app.
## Model Used
OpenAI Codex, based on GPT-6, assisted with reasoning, code edits, tool
execution, and verification. The exact deployment model ID 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
---
doc/DEVELOPING.md | 7 +
.../heartbeat-session-preparation.test.ts | 114 ++++++
server/src/services/heartbeat.ts | 301 ++------------
.../services/heartbeat/session-preparation.ts | 385 ++++++++++++++++++
4 files changed, 532 insertions(+), 275 deletions(-)
create mode 100644 server/src/__tests__/heartbeat-session-preparation.test.ts
create mode 100644 server/src/services/heartbeat/session-preparation.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 79d71fb4012..7cf3e8b1471 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1293,6 +1293,13 @@ cleanup. Public-path characterization tests are in
browser fixture also checks the persisted prompt variants and description revision
from a real process adapter before completing its task.
+Session preparation is in `server/src/services/heartbeat/session-preparation.ts`.
+It selects fresh or resumable sessions from effective config and managed-account
+identity, then resolves adapter session identity and compaction after workspace
+preparation. Both phases keep their original ordering in `heartbeat.ts`; they
+acquire no runtime resources. Public-path characterization tests live in
+`server/src/__tests__/heartbeat-session-preparation.test.ts`.
+
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
diff --git a/server/src/__tests__/heartbeat-session-preparation.test.ts b/server/src/__tests__/heartbeat-session-preparation.test.ts
new file mode 100644
index 00000000000..7460a457338
--- /dev/null
+++ b/server/src/__tests__/heartbeat-session-preparation.test.ts
@@ -0,0 +1,114 @@
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { agents, agentTaskSessions, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
+import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
+import { heartbeatService } from "../services/heartbeat.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+const describePostgres = support.supported ? describe : describe.skip;
+if (!support.supported) console.warn(`Skipping session preparation tests: ${support.reason}`);
+
+describePostgres("heartbeat session preparation through adapter dispatch", () => {
+ const adapterType = "session_preparation_test";
+ const captured = new Map();
+ let temporary: Awaited>;
+ let db: ReturnType;
+ let heartbeat: ReturnType;
+
+ beforeAll(async () => {
+ temporary = await startEmbeddedPostgresTestDatabase("heartbeat-session-preparation-");
+ db = createDb(temporary.connectionString);
+ heartbeat = heartbeatService(db);
+ registerServerAdapter({
+ type: adapterType,
+ supportsLocalAgentJwt: false,
+ execute: async (input) => {
+ captured.set(input.runId, input);
+ // Settle the fixture's work so no automatic unfinished-task retry
+ // competes with the next explicit session-continuation wake.
+ await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
+ return { exitCode: 0, signal: null, timedOut: false, sessionId: "saved-session", sessionParams: { sessionId: "saved-session", adapterState: "preserved" }, resultJson: {} };
+ },
+ testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
+ });
+ }, 30_000);
+
+ afterAll(async () => {
+ await heartbeat?.drainActiveRunExecutions();
+ unregisterServerAdapter(adapterType);
+ await db?.$client.end({ timeout: 0 });
+ await temporary?.cleanup();
+ });
+
+ async function seed(runtimeConfig: Record = {}) {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Session fixture", issuePrefix: `S${companyId.slice(0, 6)}` });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Session reader", adapterType, status: "idle", adapterConfig: { model: "model-a" }, runtimeConfig });
+ await db.insert(issues).values({ id: issueId, companyId, title: "Session fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: "session-user", issueNumber: 1 });
+ return { companyId, agentId, issueId };
+ }
+
+ async function execute(f: Awaited>, context: Record = {}) {
+ const run = await heartbeat.wakeup(f.agentId, {
+ source: "on_demand", triggerDetail: "manual", reason: "manual",
+ payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId, ...context },
+ });
+ expect(run).not.toBeNull();
+ await heartbeat.drainActiveRunExecutions();
+ const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
+ expect(finished?.status, finished?.error ?? undefined).toBe("succeeded");
+ expect(captured.has(run!.id)).toBe(true);
+ return { input: captured.get(run!.id)!, finished };
+ }
+
+ it("reuses unchanged task sessions and strips host freshness metadata before dispatch", async () => {
+ const f = await seed();
+ const first = await execute(f);
+ expect(first.input.runtime.sessionId).toBeNull();
+ const stored = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, f.agentId)).then(rows => rows[0]);
+ expect(stored?.sessionParamsJson).toHaveProperty("__paperclipConfigFingerprint");
+ const resumed = await execute(f, { paperclipSessionHandoffMarkdown: "stale handoff", paperclipSessionRotationReason: "stale reason", paperclipPreviousSessionId: "stale session" });
+ expect(resumed.input.runtime).toMatchObject({ sessionId: "saved-session", sessionDisplayId: "saved-session", taskKey: f.issueId });
+ expect(resumed.input.runtime.sessionParams).toMatchObject({ sessionId: "saved-session", adapterState: "preserved" });
+ expect(resumed.input.runtime.sessionParams).not.toHaveProperty("__paperclipConfigFingerprint");
+ expect(resumed.input.context.paperclipSessionHandoffMarkdown).toBeUndefined();
+ expect(resumed.input.context.paperclipSessionRotationReason).toBeUndefined();
+ expect(resumed.input.context.paperclipPreviousSessionId).toBeUndefined();
+ expect(resumed.finished.sessionIdBefore).toBe("saved-session");
+ });
+
+ it("starts fresh when effective model configuration changes and persists the reason", async () => {
+ const f = await seed();
+ await execute(f);
+ await db.update(agents).set({ adapterConfig: { model: "model-b" } }).where(eq(agents.id, f.agentId));
+ const next = await execute(f);
+ expect(next.input.runtime.sessionId).toBeNull();
+ expect(next.input.runtime.sessionParams).toBeNull();
+ expect(next.finished.sessionIdBefore).toBeNull();
+ expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, taskSessionAvailable: true, taskSessionReused: false } } });
+ });
+
+ it("refreshes tools with a fresh session when the selected adapter cannot refresh on resume", async () => {
+ const f = await seed();
+ await execute(f);
+ const next = await execute(f, { refreshTools: true });
+ expect(next.input.context.forceFreshSession).toBe(true);
+ expect(next.input.runtime.sessionId).toBeNull();
+ expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { resetReasons: ["forceFreshSession was requested"] } } });
+ });
+
+ it("rotates a session at the configured run threshold and supplies a handoff", async () => {
+ const f = await seed({ sessionCompaction: { enabled: true, maxSessionRuns: 1 } });
+ await execute(f);
+ await execute(f);
+ const next = await execute(f);
+ expect(next.input.runtime.sessionId).toBeNull();
+ expect(next.input.runtime.sessionParams).toBeNull();
+ expect(next.input.context.paperclipPreviousSessionId).toBe("saved-session");
+ expect(next.input.context.paperclipSessionRotationReason).toBe("session exceeded 1 runs");
+ expect(next.input.context.paperclipSessionHandoffMarkdown).toEqual(expect.any(String));
+ expect(next.finished.sessionIdBefore).toBeNull();
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index bcbcdb0a648..03274998d80 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { prepareHeartbeatSession, resolveHeartbeatAdapterSession } from "./heartbeat/session-preparation.js";
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js";
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
@@ -80,9 +81,6 @@ import {
normalizeResumeParamsForAdapter,
truncateDisplayId,
resolveLedgerScopeForRun,
- describeSessionResetReason,
- isCanonicalSessionIdForAdapter,
- requiresCanonicalSessionIds,
resolveNextSessionState,
resolveCacheAdjustedCostUsd,
resolveLedgerCostStatus,
@@ -139,16 +137,10 @@ export {
import {
buildExecutionWorkspaceConfigSnapshot,
stripWorkspaceRuntimeFromExecutionRunConfig,
- buildEffectiveRunSessionConfigMetadata,
- readConfigFingerprintFromSessionParams,
- readConfiguredModelFromAdapterConfig,
- resolveTaskSessionConfigFreshness,
- stripPaperclipSessionMetadataFromSessionParams,
resolveRuntimeSessionParamsForWorkspace,
buildRunWorkspaceHints,
buildReferencedProjectRunObservability,
assertGitSensitiveAdapterWorkspaceValid,
- type EffectiveRunSessionConfigMetadata,
createHeartbeatWorkspaceResolver,
} from "./heartbeat/workspaces.js";
export {
@@ -275,7 +267,7 @@ import {
} from "./ai-connection-runtime.js";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
-import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
+import { managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
import {
getExecutionBlocker,
} from "./execution-blocker.js";
@@ -319,7 +311,6 @@ import {
} from "node:crypto";
import {
and,
- desc,
eq,
exists,
inArray,
@@ -336,7 +327,6 @@ import {
} from "@paperclipai/shared";
import {
agents,
- agentConfigRevisions,
agentWakeupRequests,
chatActions,
heartbeatRuns,
@@ -384,7 +374,6 @@ import {
type NativeExecutionInput,
type NativeSessionBackend,
} from "../vendor/paperclip-runner/index.js";
-import { createNativeSessionHandoffLoader } from "./native-runtime/native-session-handoff.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import {
@@ -2910,143 +2899,22 @@ export function heartbeatService(
...identityRedactor.values,
...collectRunFailureSecretValues(runtimeConfig.env, secretKeys),
];
- const latestAgentConfigRevision = await getLatestAgentConfigRevision(
- agent.companyId,
- agent.id,
- );
- const sessionConfigMetadataInput = {
- agentIdentityKeyId: agentIdentity?.keyId,
- adapterType: agent.adapterType,
- effectiveAdapterConfig: runtimeConfig,
- managedAiHome: managedAiRuntime?.home,
- agentRuntimeConfig: agent.runtimeConfig,
- issueOverrides: issueAssigneeOverrides,
- workspaceConfig: {
- requestedMode: requestedExecutionWorkspaceMode,
- effectiveMode: effectiveExecutionWorkspaceMode,
- issueConfigRevisionAt:
- issueContext?.updatedAt instanceof Date
- ? issueContext.updatedAt.toISOString()
- : (issueContext?.updatedAt ?? null),
- projectConfigRevisionAt:
- projectContext?.updatedAt instanceof Date
- ? projectContext.updatedAt.toISOString()
- : (projectContext?.updatedAt ?? null),
- projectPolicy: projectExecutionWorkspacePolicy,
- issueSettings: issueExecutionWorkspaceSettings,
- reusableExecutionWorkspaceConfig:
- requestedReusableExecutionWorkspaceConfig,
- existingExecutionWorkspace: reusableExistingExecutionWorkspace
- ? {
- id: reusableExistingExecutionWorkspace.id,
- mode: reusableExistingExecutionWorkspace.mode,
- strategyType: reusableExistingExecutionWorkspace.strategyType,
- projectWorkspaceId:
- reusableExistingExecutionWorkspace.projectWorkspaceId,
- repoUrl: reusableExistingExecutionWorkspace.repoUrl,
- baseRef: reusableExistingExecutionWorkspace.baseRef,
- branchName: reusableExistingExecutionWorkspace.branchName,
- config: reusableExistingExecutionWorkspace.config,
- }
- : null,
- },
- environment: {
- selectionSource: environmentResolution.source,
- selectedEnvironmentId,
- selectedEnvironment: selectedEnvironmentForConfig
- ? {
- id: selectedEnvironmentForConfig.id,
- driver: selectedEnvironmentForConfig.driver,
- config: selectedEnvironmentForConfig.config,
- configRevisionAt:
- selectedEnvironmentForConfig.updatedAt instanceof Date
- ? selectedEnvironmentForConfig.updatedAt.toISOString()
- : (selectedEnvironmentForConfig.updatedAt ?? null),
- }
- : null,
- executionPolicy,
- },
- environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
- projectEnv: projectContext?.env ?? null,
- routineEnv: routineEnvContext.env,
- secretManifest,
- runtimeSkills: runtimeSkillEntries,
- agentConfigRevision: latestAgentConfigRevision
- ? {
- id: latestAgentConfigRevision.id,
- changedKeys: latestAgentConfigRevision.changedKeys,
- configRevisionAt:
- latestAgentConfigRevision.createdAt.toISOString(),
- }
- : null,
- };
- const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput);
- let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = [];
- if (managedAiRuntime && aiBinding && taskSession &&
- readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) {
- const revisions = requestedAiBinding?.mode === "router"
- ? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21)
- : [];
- const candidates = aiConnectionSessionCompatibilityInputs({
- effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
- agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision,
- issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides,
- binding: aiBinding, router: requestedAiBinding?.mode === "router",
- storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity,
- sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions,
- });
- compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate })));
- }
- const configuredModel =
- readConfiguredModelFromAdapterConfig(runtimeConfig);
- if (context.refreshTools === true && agent.adapterType !== "paperclip_runner") {
- const capability = getServerAdapter(agent.adapterType).supportsToolRefreshOnResume;
- const canRefresh = typeof capability === "function" ? capability(runtimeConfig) : capability === true;
- if (!canRefresh) context.forceFreshSession = true;
- }
- const wakeSessionResetReason = describeSessionResetReason(context);
- const sessionConfigFreshness = resolveTaskSessionConfigFreshness({
- hasTaskSession: taskSession != null,
- configuredModel,
- taskSessionParams:
- taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
- configMetadata: sessionConfigMetadata,
- compatibleConfigMetadata,
- wakeResetReason: wakeSessionResetReason,
- preserveLegacySessionWithoutConfigMetadata:
- acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity,
+ const preparedSession = await prepareHeartbeatSession(db, {
+ run, agent,
+ task: { context, issueRef, issueContext, projectContext, wakeCommentId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision },
+ session: { taskSession, taskSessionDecodedParams, explicitResumeSessionParams, explicitResumeSessionDisplayId, sessionCodec },
+ config: {
+ runtimeConfig, agentIdentity, issueAssigneeOverrides, originalAiIssueOverrides,
+ managedAiRuntime, aiBinding, requestedAiBinding, secretManifest, runtimeSkillEntries, routineEnvContext,
+ },
+ workspace: {
+ requestedExecutionWorkspaceMode, effectiveExecutionWorkspaceMode, projectExecutionWorkspacePolicy,
+ issueExecutionWorkspaceSettings, executionPolicy, requestedReusableExecutionWorkspaceConfig, reusableExistingExecutionWorkspace,
+ },
+ environment: { environmentResolution, selectedEnvironmentId, selectedEnvironmentForConfig },
+ getLatestAgentConfigRevision,
});
- const resetTaskSession =
- shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
- const sessionResetReason =
- sessionConfigFreshness.reasons.join("; ") || null;
- const taskSessionForRun = resetTaskSession ? null : taskSession;
- const getFreshSessionHandoff = issueRef ? createNativeSessionHandoffLoader({
- db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id, before: run.createdAt,
- throughCommentId: readNonEmptyString(context.conversationReplayThroughCommentId) ?? (context.interactionKind ? null : wakeCommentId),
- }) : undefined;
- const previousSessionParams =
- explicitResumeSessionParams ??
- (isCanonicalSessionIdForAdapter(
- agent.adapterType,
- explicitResumeSessionDisplayId,
- )
- ? { sessionId: explicitResumeSessionDisplayId }
- : null) ??
- normalizeResumeParamsForAdapter(
- agent.adapterType,
- stripPaperclipSessionMetadataFromSessionParams(
- sessionCodec.deserialize(
- taskSessionForRun?.sessionParamsJson ?? null,
- ),
- ),
- );
- // Legacy plugins can consume the existing context field on a known-fresh
- // dispatch. Built-ins also load lazily if their resume attempt fails.
- if (agent.adapterType !== "paperclip_runner" && !previousSessionParams && getFreshSessionHandoff) {
- const handoff = await getFreshSessionHandoff();
- if (handoff) context.paperclipFreshSessionHandoffMarkdown = handoff;
- }
+ const { sessionConfigMetadata, configuredModel, resetTaskSession, sessionResetReason, taskSessionForRun, getFreshSessionHandoff, previousSessionParams } = preparedSession;
const {
resolvedWorkspace,
hostExecutionWorkspaceConfig,
@@ -3500,135 +3368,18 @@ export function heartbeatService(
) {
context.projectId = executionWorkspace.projectId;
}
- const runtimeSessionFallback =
- taskKey || resetTaskSession
- ? null
- : isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
- ? runtime.sessionId
- : null;
- const runtimeSessionDisplayId = truncateDisplayId(
- explicitResumeSessionDisplayId ??
- taskSessionForRun?.sessionDisplayId ??
- (sessionCodec.getDisplayId
- ? sessionCodec.getDisplayId(runtimeSessionParams)
- : null) ??
- readNonEmptyString(runtimeSessionParams?.sessionId) ??
- runtimeSessionFallback,
- );
- let previousSessionDisplayId = requiresCanonicalSessionIds(
- agent.adapterType,
- )
- ? truncateDisplayId(
- readNonEmptyString(previousSessionParams?.sessionId) ??
- (isCanonicalSessionIdForAdapter(
- agent.adapterType,
- runtimeSessionDisplayId,
- )
- ? runtimeSessionDisplayId
- : null) ??
- runtimeSessionFallback,
- )
- : runtimeSessionDisplayId;
- let runtimeSessionIdForAdapter =
- readNonEmptyString(runtimeSessionParams?.sessionId) ??
- runtimeSessionFallback;
- let runtimeSessionParamsForAdapter = normalizeSessionParams(
- stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
- );
-
- const sessionCompaction = await evaluateSessionCompaction({
+ const {
+ previousSessionDisplayId, runtimeSessionParamsForAdapter, taskSessionCredentialCompatible, runtimeForAdapter, configFreshnessResultMetadata, sessionCompaction,
+ } = await resolveHeartbeatAdapterSession({
agent,
- sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
- issueId,
- continuationSummaryBody: continuationSummary?.body ?? null,
- });
- if (sessionCompaction.rotate) {
- context.paperclipSessionHandoffMarkdown =
- sessionCompaction.handoffMarkdown;
- context.paperclipSessionRotationReason = sessionCompaction.reason;
- context.paperclipPreviousSessionId =
- previousSessionDisplayId ?? runtimeSessionIdForAdapter;
- runtimeSessionIdForAdapter = null;
- runtimeSessionParamsForAdapter = null;
- previousSessionDisplayId = null;
- if (sessionCompaction.reason) {
- runtimeWorkspaceWarnings.push(
- `Starting a fresh session because ${sessionCompaction.reason}.`,
- );
- }
- } else {
- delete context.paperclipSessionHandoffMarkdown;
- delete context.paperclipSessionRotationReason;
- delete context.paperclipPreviousSessionId;
- }
-
- const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible(
- taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity,
- managedAiRuntime.sessionIdentity,
- managedAiRuntime.identity,
- );
- if (managedAiRuntime) {
- sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
- if (!taskSessionCredentialCompatible) {
- runtimeSessionIdForAdapter = null;
- runtimeSessionParamsForAdapter = null;
- previousSessionDisplayId = null;
- delete executionContinuation?.resumeDelta;
- }
- }
- const runtimeForAdapter = {
- sessionId: runtimeSessionIdForAdapter,
- sessionParams: runtimeSessionParamsForAdapter,
- sessionDisplayId: previousSessionDisplayId,
- taskKey,
- };
- // A delta is safe only when the selected provider session is exactly the
- // task session whose last dispatch supplied the baseline history.
- if (
- executionContinuation?.resumeDelta &&
- (!taskSessionForRun ||
- !taskSession?.sessionDisplayId ||
- runtimeForAdapter.sessionDisplayId !== taskSession.sessionDisplayId ||
- resetTaskSession ||
- context.forceFreshSession === true)
- ) {
- delete executionContinuation.resumeDelta;
- }
- const configFreshnessResultMetadata = {
- version: sessionConfigMetadata.version,
- session: {
- fingerprintVersion: sessionConfigMetadata.version,
- categories: sessionConfigMetadata.categories,
- reset: resetTaskSession,
- resetReasons: sessionConfigFreshness.reasons,
- changedCategories: sessionConfigFreshness.changedCategories,
- taskSessionAvailable: taskSession != null,
- taskSessionReused: taskSessionForRun != null,
- storedFingerprintPresent: Boolean(
- sessionConfigFreshness.storedFingerprint,
- ),
- nextFingerprint: sessionConfigFreshness.nextFingerprint,
- },
+ task: { context, taskKey, issueId, continuationSummary, executionContinuation },
+ session: { runtime, taskSession, sessionCodec, explicitResumeSessionDisplayId, runtimeSessionParams, managedAiRuntime, prepared: preparedSession },
workspace: {
- fingerprintVersion: latestWorkspaceConfigMetadata.version,
- categories: latestWorkspaceConfigMetadata.categories,
- action: workspaceConfigFreshness.action,
- changedCategories: workspaceConfigFreshness.changedCategories,
- reasons: workspaceConfigFreshness.reasons,
- reuseRequested: requestedShouldReuseExisting,
- workspaceReused: Boolean(reusedExecutionWorkspace),
- configSnapshotRefreshed:
- resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
- storedFingerprintPresent:
- workspaceConfigFreshness.storedFingerprintPresent,
- storedFingerprint: workspaceConfigFreshness.storedFingerprint,
- inferredFingerprint: workspaceConfigFreshness.inferredFingerprint,
- nextFingerprint: workspaceConfigFreshness.nextFingerprint,
- previousWorkspaceId:
- workspaceReuseRequest.requestedExecutionWorkspaceId,
- activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
+ latestWorkspaceConfigMetadata, workspaceConfigFreshness, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy,
+ persistedExecutionWorkspace, runtimeWorkspaceWarnings, requestedShouldReuseExisting, workspaceReuseRequest,
},
- };
+ evaluateSessionCompaction,
+ });
let handle: RunLogHandle | null = null;
const goalCheckpointSession: {
diff --git a/server/src/services/heartbeat/session-preparation.ts b/server/src/services/heartbeat/session-preparation.ts
new file mode 100644
index 00000000000..a7845726dc0
--- /dev/null
+++ b/server/src/services/heartbeat/session-preparation.ts
@@ -0,0 +1,385 @@
+import { and, desc, eq } from "drizzle-orm";
+import { agentConfigRevisions, type Db, type agents, type agentTaskSessions, type heartbeatRuns, type issues, type projects } from "@paperclipai/db";
+import type { AiConnectionBinding, AiRuntimeConnectionBinding } from "@paperclipai/shared";
+import { getServerAdapter } from "../../adapters/index.js";
+import type { prepareManagedAiRuntime } from "../ai-connection-runtime.js";
+import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "../ai-connection-session.js";
+import { createNativeSessionHandoffLoader } from "../native-runtime/native-session-handoff.js";
+import {
+ describeSessionResetReason, shouldResetTaskSessionForWake, isCanonicalSessionIdForAdapter,
+ normalizeResumeParamsForAdapter, normalizeSessionParams, requiresCanonicalSessionIds,
+ truncateDisplayId, type createHeartbeatRunState, type getAdapterSessionCodec,
+} from "./run-state.js";
+import {
+ buildEffectiveRunSessionConfigMetadata, readConfigFingerprintFromSessionParams,
+ readConfiguredModelFromAdapterConfig, resolveTaskSessionConfigFreshness,
+ stripPaperclipSessionMetadataFromSessionParams, type EffectiveRunSessionConfigMetadata,
+} from "./workspaces.js";
+import type { HeartbeatWorkspacePreparationInput, prepareHeartbeatWorkspace } from "./workspace-preparation.js";
+import type { prepareHeartbeatTaskContext } from "./task-context.js";
+
+type RunState = ReturnType;
+type WorkspaceInput = HeartbeatWorkspacePreparationInput;
+type PreparedWorkspace = Awaited>;
+type TaskContext = Awaited>;
+type SessionMetadataInput = Parameters[0];
+type ManagedAiRuntime = Awaited>;
+type TaskSession = typeof agentTaskSessions.$inferSelect | null;
+type SessionCodec = ReturnType;
+
+export interface HeartbeatSessionPreparationInput {
+ run: Pick;
+ agent: typeof agents.$inferSelect;
+ task: {
+ context: Record;
+ issueRef: Pick | null;
+ issueContext: Pick | null;
+ projectContext: Pick | null;
+ wakeCommentId: string | null;
+ acceptedPlanContinuationWake: boolean;
+ acceptedPlanWakeRoutingDecision: unknown;
+ };
+ session: {
+ taskSession: TaskSession;
+ taskSessionDecodedParams: Record | null;
+ explicitResumeSessionParams: Record | null;
+ explicitResumeSessionDisplayId: string | null;
+ sessionCodec: SessionCodec;
+ };
+ config: {
+ runtimeConfig: Record;
+ agentIdentity: { keyId: string } | undefined;
+ issueAssigneeOverrides: unknown;
+ originalAiIssueOverrides: unknown;
+ managedAiRuntime: ManagedAiRuntime | undefined;
+ aiBinding: AiConnectionBinding | undefined;
+ requestedAiBinding: AiRuntimeConnectionBinding | undefined;
+ secretManifest: SessionMetadataInput["secretManifest"];
+ runtimeSkillEntries: SessionMetadataInput["runtimeSkills"];
+ routineEnvContext: { env: unknown };
+ };
+ workspace: Pick & {
+ requestedReusableExecutionWorkspaceConfig: NonNullable["config"] | null;
+ reusableExistingExecutionWorkspace: WorkspaceInput["reuse"]["reusableExistingExecutionWorkspace"];
+ };
+ environment: Pick;
+ getLatestAgentConfigRevision: RunState["getLatestAgentConfigRevision"];
+}
+
+function readNonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
+}
+
+/** Selects a resumable session before workspace preparation; acquires no runtime resources. */
+export async function prepareHeartbeatSession(db: Db, input: HeartbeatSessionPreparationInput) {
+ const { run, agent, getLatestAgentConfigRevision } = input;
+ const { context, issueRef, issueContext, projectContext, wakeCommentId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision } = input.task;
+ const { taskSession, taskSessionDecodedParams, explicitResumeSessionParams, explicitResumeSessionDisplayId, sessionCodec } = input.session;
+ const { runtimeConfig, agentIdentity, issueAssigneeOverrides, originalAiIssueOverrides, managedAiRuntime, aiBinding, requestedAiBinding, secretManifest, runtimeSkillEntries, routineEnvContext } = input.config;
+ const { requestedExecutionWorkspaceMode, effectiveExecutionWorkspaceMode, projectExecutionWorkspacePolicy, issueExecutionWorkspaceSettings, executionPolicy, requestedReusableExecutionWorkspaceConfig, reusableExistingExecutionWorkspace } = input.workspace;
+ const { environmentResolution, selectedEnvironmentId, selectedEnvironmentForConfig } = input.environment;
+ const latestAgentConfigRevision = await getLatestAgentConfigRevision(
+ agent.companyId,
+ agent.id,
+ );
+ const sessionConfigMetadataInput = {
+ agentIdentityKeyId: agentIdentity?.keyId,
+ adapterType: agent.adapterType,
+ effectiveAdapterConfig: runtimeConfig,
+ managedAiHome: managedAiRuntime?.home,
+ agentRuntimeConfig: agent.runtimeConfig,
+ issueOverrides: issueAssigneeOverrides,
+ workspaceConfig: {
+ requestedMode: requestedExecutionWorkspaceMode,
+ effectiveMode: effectiveExecutionWorkspaceMode,
+ issueConfigRevisionAt:
+ issueContext?.updatedAt instanceof Date
+ ? issueContext.updatedAt.toISOString()
+ : (issueContext?.updatedAt ?? null),
+ projectConfigRevisionAt:
+ projectContext?.updatedAt instanceof Date
+ ? projectContext.updatedAt.toISOString()
+ : (projectContext?.updatedAt ?? null),
+ projectPolicy: projectExecutionWorkspacePolicy,
+ issueSettings: issueExecutionWorkspaceSettings,
+ reusableExecutionWorkspaceConfig:
+ requestedReusableExecutionWorkspaceConfig,
+ existingExecutionWorkspace: reusableExistingExecutionWorkspace
+ ? {
+ id: reusableExistingExecutionWorkspace.id,
+ mode: reusableExistingExecutionWorkspace.mode,
+ strategyType: reusableExistingExecutionWorkspace.strategyType,
+ projectWorkspaceId:
+ reusableExistingExecutionWorkspace.projectWorkspaceId,
+ repoUrl: reusableExistingExecutionWorkspace.repoUrl,
+ baseRef: reusableExistingExecutionWorkspace.baseRef,
+ branchName: reusableExistingExecutionWorkspace.branchName,
+ config: reusableExistingExecutionWorkspace.config,
+ }
+ : null,
+ },
+ environment: {
+ selectionSource: environmentResolution.source,
+ selectedEnvironmentId,
+ selectedEnvironment: selectedEnvironmentForConfig
+ ? {
+ id: selectedEnvironmentForConfig.id,
+ driver: selectedEnvironmentForConfig.driver,
+ config: selectedEnvironmentForConfig.config,
+ configRevisionAt:
+ selectedEnvironmentForConfig.updatedAt instanceof Date
+ ? selectedEnvironmentForConfig.updatedAt.toISOString()
+ : (selectedEnvironmentForConfig.updatedAt ?? null),
+ }
+ : null,
+ executionPolicy,
+ },
+ environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
+ projectEnv: projectContext?.env ?? null,
+ routineEnv: routineEnvContext.env,
+ secretManifest,
+ runtimeSkills: runtimeSkillEntries,
+ agentConfigRevision: latestAgentConfigRevision
+ ? {
+ id: latestAgentConfigRevision.id,
+ changedKeys: latestAgentConfigRevision.changedKeys,
+ configRevisionAt:
+ latestAgentConfigRevision.createdAt.toISOString(),
+ }
+ : null,
+ };
+ const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput);
+ let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = [];
+ if (managedAiRuntime && aiBinding && taskSession &&
+ readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) {
+ const revisions = requestedAiBinding?.mode === "router"
+ ? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21)
+ : [];
+ const candidates = aiConnectionSessionCompatibilityInputs({
+ effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
+ agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision,
+ issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides,
+ binding: aiBinding, router: requestedAiBinding?.mode === "router",
+ storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity,
+ sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions,
+ });
+ compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate })));
+ }
+ const configuredModel =
+ readConfiguredModelFromAdapterConfig(runtimeConfig);
+ if (context.refreshTools === true && agent.adapterType !== "paperclip_runner") {
+ const capability = getServerAdapter(agent.adapterType).supportsToolRefreshOnResume;
+ const canRefresh = typeof capability === "function" ? capability(runtimeConfig) : capability === true;
+ if (!canRefresh) context.forceFreshSession = true;
+ }
+ const wakeSessionResetReason = describeSessionResetReason(context);
+ const sessionConfigFreshness = resolveTaskSessionConfigFreshness({
+ hasTaskSession: taskSession != null,
+ configuredModel,
+ taskSessionParams:
+ taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
+ configMetadata: sessionConfigMetadata,
+ compatibleConfigMetadata,
+ wakeResetReason: wakeSessionResetReason,
+ preserveLegacySessionWithoutConfigMetadata:
+ acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity,
+ });
+ const resetTaskSession =
+ shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
+ const sessionResetReason =
+ sessionConfigFreshness.reasons.join("; ") || null;
+ const taskSessionForRun = resetTaskSession ? null : taskSession;
+ const getFreshSessionHandoff = issueRef ? createNativeSessionHandoffLoader({
+ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id, before: run.createdAt,
+ throughCommentId: readNonEmptyString(context.conversationReplayThroughCommentId) ?? (context.interactionKind ? null : wakeCommentId),
+ }) : undefined;
+ const previousSessionParams =
+ explicitResumeSessionParams ??
+ (isCanonicalSessionIdForAdapter(
+ agent.adapterType,
+ explicitResumeSessionDisplayId,
+ )
+ ? { sessionId: explicitResumeSessionDisplayId }
+ : null) ??
+ normalizeResumeParamsForAdapter(
+ agent.adapterType,
+ stripPaperclipSessionMetadataFromSessionParams(
+ sessionCodec.deserialize(
+ taskSessionForRun?.sessionParamsJson ?? null,
+ ),
+ ),
+ );
+ // Legacy plugins can consume the existing context field on a known-fresh
+ // dispatch. Built-ins also load lazily if their resume attempt fails.
+ if (agent.adapterType !== "paperclip_runner" && !previousSessionParams && getFreshSessionHandoff) {
+ const handoff = await getFreshSessionHandoff();
+ if (handoff) context.paperclipFreshSessionHandoffMarkdown = handoff;
+ }
+ return { sessionConfigMetadata, configuredModel, sessionConfigFreshness, resetTaskSession, sessionResetReason, taskSessionForRun, getFreshSessionHandoff, previousSessionParams };
+}
+
+export interface HeartbeatAdapterSessionInput {
+ agent: typeof agents.$inferSelect;
+ task: {
+ context: Record;
+ taskKey: string | null;
+ issueId: string | null;
+ continuationSummary: TaskContext["continuationSummary"];
+ executionContinuation: TaskContext["executionContinuation"];
+ };
+ session: {
+ runtime: { sessionId: string | null };
+ taskSession: TaskSession;
+ sessionCodec: SessionCodec;
+ explicitResumeSessionDisplayId: string | null;
+ runtimeSessionParams: Record | null;
+ managedAiRuntime: ManagedAiRuntime | undefined;
+ prepared: Awaited>;
+ };
+ workspace: Pick & {
+ runtimeWorkspaceWarnings: string[];
+ requestedShouldReuseExisting: boolean;
+ workspaceReuseRequest: WorkspaceInput["reuse"]["workspaceReuseRequest"];
+ };
+ evaluateSessionCompaction: RunState["evaluateSessionCompaction"];
+}
+
+/** Resolves adapter identity after workspace preparation, retaining in-place context and delta updates. */
+export async function resolveHeartbeatAdapterSession(input: HeartbeatAdapterSessionInput) {
+ const { agent, evaluateSessionCompaction } = input;
+ const { context, taskKey, issueId, continuationSummary, executionContinuation } = input.task;
+ const { runtime, taskSession, sessionCodec, explicitResumeSessionDisplayId, runtimeSessionParams, managedAiRuntime, prepared } = input.session;
+ const { sessionConfigMetadata, sessionConfigFreshness, resetTaskSession, taskSessionForRun, previousSessionParams } = prepared;
+ const { latestWorkspaceConfigMetadata, workspaceConfigFreshness, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy, persistedExecutionWorkspace, runtimeWorkspaceWarnings, requestedShouldReuseExisting, workspaceReuseRequest } = input.workspace;
+ const runtimeSessionFallback =
+ taskKey || resetTaskSession
+ ? null
+ : isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
+ ? runtime.sessionId
+ : null;
+ const runtimeSessionDisplayId = truncateDisplayId(
+ explicitResumeSessionDisplayId ??
+ taskSessionForRun?.sessionDisplayId ??
+ (sessionCodec.getDisplayId
+ ? sessionCodec.getDisplayId(runtimeSessionParams)
+ : null) ??
+ readNonEmptyString(runtimeSessionParams?.sessionId) ??
+ runtimeSessionFallback,
+ );
+ let previousSessionDisplayId = requiresCanonicalSessionIds(
+ agent.adapterType,
+ )
+ ? truncateDisplayId(
+ readNonEmptyString(previousSessionParams?.sessionId) ??
+ (isCanonicalSessionIdForAdapter(
+ agent.adapterType,
+ runtimeSessionDisplayId,
+ )
+ ? runtimeSessionDisplayId
+ : null) ??
+ runtimeSessionFallback,
+ )
+ : runtimeSessionDisplayId;
+ let runtimeSessionIdForAdapter =
+ readNonEmptyString(runtimeSessionParams?.sessionId) ??
+ runtimeSessionFallback;
+ let runtimeSessionParamsForAdapter = normalizeSessionParams(
+ stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
+ );
+
+ const sessionCompaction = await evaluateSessionCompaction({
+ agent,
+ sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
+ issueId,
+ continuationSummaryBody: continuationSummary?.body ?? null,
+ });
+ if (sessionCompaction.rotate) {
+ context.paperclipSessionHandoffMarkdown =
+ sessionCompaction.handoffMarkdown;
+ context.paperclipSessionRotationReason = sessionCompaction.reason;
+ context.paperclipPreviousSessionId =
+ previousSessionDisplayId ?? runtimeSessionIdForAdapter;
+ runtimeSessionIdForAdapter = null;
+ runtimeSessionParamsForAdapter = null;
+ previousSessionDisplayId = null;
+ if (sessionCompaction.reason) {
+ runtimeWorkspaceWarnings.push(
+ `Starting a fresh session because ${sessionCompaction.reason}.`,
+ );
+ }
+ } else {
+ delete context.paperclipSessionHandoffMarkdown;
+ delete context.paperclipSessionRotationReason;
+ delete context.paperclipPreviousSessionId;
+ }
+
+ const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible(
+ taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity,
+ managedAiRuntime.sessionIdentity,
+ managedAiRuntime.identity,
+ );
+ if (managedAiRuntime) {
+ sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
+ if (!taskSessionCredentialCompatible) {
+ runtimeSessionIdForAdapter = null;
+ runtimeSessionParamsForAdapter = null;
+ previousSessionDisplayId = null;
+ delete executionContinuation?.resumeDelta;
+ }
+ }
+ const runtimeForAdapter = {
+ sessionId: runtimeSessionIdForAdapter,
+ sessionParams: runtimeSessionParamsForAdapter,
+ sessionDisplayId: previousSessionDisplayId,
+ taskKey,
+ };
+ // A delta is safe only when the selected provider session is exactly the
+ // task session whose last dispatch supplied the baseline history.
+ if (
+ executionContinuation?.resumeDelta &&
+ (!taskSessionForRun ||
+ !taskSession?.sessionDisplayId ||
+ runtimeForAdapter.sessionDisplayId !== taskSession.sessionDisplayId ||
+ resetTaskSession ||
+ context.forceFreshSession === true)
+ ) {
+ delete executionContinuation.resumeDelta;
+ }
+ const configFreshnessResultMetadata = {
+ version: sessionConfigMetadata.version,
+ session: {
+ fingerprintVersion: sessionConfigMetadata.version,
+ categories: sessionConfigMetadata.categories,
+ reset: resetTaskSession,
+ resetReasons: sessionConfigFreshness.reasons,
+ changedCategories: sessionConfigFreshness.changedCategories,
+ taskSessionAvailable: taskSession != null,
+ taskSessionReused: taskSessionForRun != null,
+ storedFingerprintPresent: Boolean(
+ sessionConfigFreshness.storedFingerprint,
+ ),
+ nextFingerprint: sessionConfigFreshness.nextFingerprint,
+ },
+ workspace: {
+ fingerprintVersion: latestWorkspaceConfigMetadata.version,
+ categories: latestWorkspaceConfigMetadata.categories,
+ action: workspaceConfigFreshness.action,
+ changedCategories: workspaceConfigFreshness.changedCategories,
+ reasons: workspaceConfigFreshness.reasons,
+ reuseRequested: requestedShouldReuseExisting,
+ workspaceReused: Boolean(reusedExecutionWorkspace),
+ configSnapshotRefreshed:
+ resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
+ storedFingerprintPresent:
+ workspaceConfigFreshness.storedFingerprintPresent,
+ storedFingerprint: workspaceConfigFreshness.storedFingerprint,
+ inferredFingerprint: workspaceConfigFreshness.inferredFingerprint,
+ nextFingerprint: workspaceConfigFreshness.nextFingerprint,
+ previousWorkspaceId:
+ workspaceReuseRequest.requestedExecutionWorkspaceId,
+ activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
+ },
+ };
+
+ return { previousSessionDisplayId, runtimeSessionParamsForAdapter, taskSessionCredentialCompatible, runtimeForAdapter, configFreshnessResultMetadata, sessionCompaction };
+}
From de79750939bac6b566fefce48a7268f29e3aea3f Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:49:33 -0500
Subject: [PATCH 07/23] refactor(chat): extract identity linking (#15836)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip connects provider identities to company members.
> - A confirmed identity determines whose permissions apply to chat
messages.
> - Token consumption and membership checks must remain atomic.
> - These operations currently share the large chat channel service.
> - This PR moves identity linking into one module with explicit runtime
callbacks.
> - Reviewers can inspect the complete identity-link flow together.
## Linked Issues or Issue Description
Refs #15834 and #15823. Stack slice 3 of 5, based on
`codex/chat-resource-reconciliation`.
Merge the predecessor first, then retarget this PR. Do not merge the
stack out of order.
Stack order: #15823 (provider messages) → #15834 (resource
reconciliation) → #15836 (identity links) → #15844 (publication
replacement) → #15845 (failed-run retries). GitHub native stack merging
preserves this order and automatically restacks the remaining branches.
## What Changed
- Extract company-scoped identity linking into shared
`identity-links.ts`.
- Move Slack connect receipt queries, access requests and
confirmation-notice staging into `slack/identity-links.ts`.
- Move GitHub identity fields into `github/identity-links.ts`.
- Preserve shared token consumption, link locks, membership checks and
revocation.
- Stage the Slack notice inside the original confirmation transaction.
Schedule delivery after commit. Keep runtime and public URL callbacks
live.
- Restack on the revised resource PR #15834 and record the architectural
reassessment in the plan.
## Verification
- Four existing service integration cases passed before and after. The
Slack connect case covers discovery, preview, access requests, duplicate
requests, member confirmation, provider notice, consumed tokens and
endpoint isolation.
- Receipt queries, the access-request transaction and notice payload
match the previous implementation with explicit parameter substitutions.
- Server typecheck, module-boundary and whitespace checks passed.
- These are local database and fixture transport tests, not live Slack
execution.
- Fresh Greptile review is 5/5 on
`7b44962f09fe8043c77ed40e05f761f31c64b33c`, with a successful check and
no open review threads.
- Final-stack `pnpm -r typecheck` and `pnpm build` passed at
`57ec5ba94aecd860471f9b14db46f99120c7c790`.
- All CI checks passed on `62ee045a4728da2d756be3d2e40d13e592a0b5a9`.
- The [original workspace
shard](https://github.com/paperclipai/paperclip/actions/runs/38256203686/job/114834873234)
timed out waiting for the CLI import/export test server health endpoint.
Its [single unchanged rerun
passed](https://github.com/paperclipai/paperclip/actions/runs/38256203686/job/114838954081).
The original timeout remains part of the evidence.
- The final full-local-suite outcome for this cumulative stack is
recorded in #15845; do not infer that gate from the focused tests above.
- Merge verification on the restacked tree: 118 focused identity,
publication, Photon and retry integration tests passed; server typecheck
and module-boundary checks passed. GitHub’s rebased trees exactly match
the previously reviewed file contents.
- All current-head required checks passed on
`7b44962f09fe8043c77ed40e05f761f31c64b33c`: full CI; Greptile 5/5 with
no unresolved threads.
## Risks
- Confirmation must retain transaction and token ownership. The Slack
helper receives the existing transaction and stages the same effect;
scheduling remains after commit in the shared coordinator.
- This PR depends on #15834. Merge predecessors and retarget before
merging this PR.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning and repository tools. The
exact runtime model ID and context-window size are not exposed in this
session. No separate agent was used.
## 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
- [ ] 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
---
.../2026-10-10-chat-channels-refactor.md | 31 ++
server/src/services/chat-channels.ts | 435 +-----------------
.../chat-channels/github/identity-links.ts | 7 +
.../services/chat-channels/identity-links.ts | 397 ++++++++++++++++
.../chat-channels/slack/identity-links.ts | 113 +++++
5 files changed, 553 insertions(+), 430 deletions(-)
create mode 100644 server/src/services/chat-channels/github/identity-links.ts
create mode 100644 server/src/services/chat-channels/identity-links.ts
create mode 100644 server/src/services/chat-channels/slack/identity-links.ts
diff --git a/doc/plans/2026-10-10-chat-channels-refactor.md b/doc/plans/2026-10-10-chat-channels-refactor.md
index 2b6765a72ea..ce51837f239 100644
--- a/doc/plans/2026-10-10-chat-channels-refactor.md
+++ b/doc/plans/2026-10-10-chat-channels-refactor.md
@@ -172,3 +172,34 @@ master, then retain the remaining four PRs for review in dependency order.
- Existing public-service tests: 15 resource cases and the Telegram migration
case passed before; the same 16 cases passed after. The name filter excludes
unrelated cases. No live third-party provider calls were made.
+
+### Slice 3: identity linking
+
+Extract six identity-link operations into `identity-links.ts`. Keep the live
+public URL getter. Fetch confirmation runtime authority through a callback that
+reads the existing service map after awaiting runtime creation. Keep the same
+transaction locks, token consumption, membership checks and post-commit notice.
+
+- Source: 37,720 lines / 1,454,762 bytes → 37,295 lines / 1,436,867 bytes.
+- Destination: 459 lines / 19,970 bytes.
+- Baseline: complete CI and 5/5 review on slice 2 (`c01f34ad2b`, PR #15834).
+- Server typecheck, module boundaries and mechanical comparison pass. The only
+ body substitution moves the runtime lookup behind the supplied live callback.
+ All remaining statements are unchanged. Local PostgreSQL remains unavailable;
+ record this slice's complete CI and review in its PR before continuing.
+
+### 2026-10-12 channel-folder reassessment: slice 3
+
+Keep identity-token consumption, principal scope, membership checks, link locks
+and revocation in shared `identity-links.ts`. Move Slack receipt queries,
+self-service access requests and confirmation-notice staging into
+`slack/identity-links.ts`. GitHub identity fields live in `github/identity-links.ts`.
+The shared confirmation transaction still stages the Slack notice before commit
+and schedules it only after commit. The live runtime and public URL callbacks
+remain live. The Slack factory holds no cache or worker state.
+
+Four existing public-service cases passed before and after, including the full
+Slack connect journey: discovery, preview, outsider access request, duplicate
+request, member confirmation, provider notice, consumed-token rejection and
+endpoint isolation. Receipt queries, access-request transaction and notice
+payload retain their statements with explicit parameter substitutions.
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index a0b5c93324c..5930982dd0b 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -1,3 +1,4 @@
+import { createChatIdentityLinks } from "./chat-channels/identity-links.js";
import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js";
import { githubRepositoryInventoryItemFromPayload } from "./chat-channels/github/resources.js";
import { baseTeamsConversationId } from "./chat-channels/microsoft-teams/resources.js";
@@ -161,7 +162,6 @@ import {
agentWakeupRequests,
agents,
assets,
- authUsers,
chatActions,
chatConversations,
chatDeliveries,
@@ -174,7 +174,6 @@ import {
chatPublications,
chatSdkState,
chatTeamsFileTransfers,
- companies,
companyMemberships,
companySecretBindings,
heartbeatRunEvents,
@@ -187,8 +186,6 @@ import {
issueThreadInteractions,
issueQuestionResponseDeliveries,
issues,
- invites,
- joinRequests,
toolApplications,
toolConnections,
} from "@paperclipai/db";
@@ -2406,6 +2403,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
// Resolve its live identity when producing URLs, not once at service startup.
// An explicit webhook ingress remains separate from board/identity links.
const getPublicBaseUrl = () => runtimeCanonicalOrigin() ?? configuredPublicBaseUrl;
+ const { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess, confirmIdentityLink, revokeLink } = createChatIdentityLinks(db, {
+ endpointRecord, getPublicBaseUrl, stageProviderEffect, scheduleProviderEffect,
+ confirmationRuntimeFor: async (endpoint) => runtimeContexts.get((await runtimeFor(endpoint)) as object),
+ });
// Task links must validate the original configured URL before normalization
// can remove credentials or other evidence that makes it unsafe to publish.
const getTaskBaseUrl = () => runtimeCanonicalOrigin() ?? options.publicBaseUrl;
@@ -27021,432 +27022,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
return reactionCount + rows.length;
}
- async function listPrincipals(endpointId: string) {
- const record = await endpointRecord(endpointId);
- if (!record) throw notFound("Chat endpoint not found");
- const principals = await db
- .select()
- .from(chatExternalPrincipals)
- .where(
- and(
- eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
- eq(chatExternalPrincipals.provider, record.endpoint.provider),
- eq(
- chatExternalPrincipals.providerAccountId,
- record.endpoint.providerAccountId ?? "unknown",
- ),
- ),
- )
- .orderBy(asc(chatExternalPrincipals.displayName));
- const links = await db
- .select()
- .from(chatIdentityLinks)
- .where(eq(chatIdentityLinks.endpointId, endpointId));
- const connects = record.endpoint.provider === "slack" ? await db
- .select({ principalId: chatActions.principalId, lastConnectAt: sql`max(${chatActions.createdAt})::text` })
- .from(chatActions)
- .where(and(
- eq(chatActions.companyId, record.endpoint.companyId),
- eq(chatActions.endpointId, endpointId),
- eq(chatActions.kind, "slack_connect"),
- eq(chatActions.status, "processed"),
- ))
- .groupBy(chatActions.principalId) : [];
- const connectByPrincipal = new Map(connects.map((row) => [row.principalId, new Date(row.lastConnectAt).toISOString()]));
- const userIds = links.flatMap((link) =>
- link.paperclipUserId ? [link.paperclipUserId] : [],
- );
- const users = userIds.length
- ? await db
- .select({
- id: authUsers.id,
- name: authUsers.name,
- email: authUsers.email,
- })
- .from(authUsers)
- .where(inArray(authUsers.id, userIds))
- : [];
- const linkByPrincipal = new Map(
- links.map((link) => [link.principalId, link]),
- );
- const userById = new Map(users.map((user) => [user.id, user]));
- return principals.map((principal) => {
- const link = linkByPrincipal.get(principal.id);
- const user = link?.paperclipUserId
- ? userById.get(link.paperclipUserId)
- : null;
- return {
- id: link?.id ?? principal.id,
- principalId: principal.id,
- ...(record.endpoint.provider === "github" ? { githubUserId: principal.externalId, githubLogin: principal.handle } : {}),
- externalLabel:
- principal.displayName ?? principal.handle ?? principal.externalId,
- externalDetail: principal.handle
- ? `@${principal.handle.replace(/^@/, "")}`
- : principal.externalId,
- paperclipUserId: link?.paperclipUserId ?? null,
- paperclipUserLabel: user?.name ?? user?.email ?? null,
- status: link?.status ?? "pending",
- lastConnectAt: connectByPrincipal.get(principal.id) ?? null,
- };
- });
- }
-
- async function createLinkIntent(
- endpointId: string,
- principalId: string,
- expiresInSeconds: number,
- ) {
- const record = await endpointRecord(endpointId);
- if (!record) throw notFound("Chat endpoint not found");
- const principal = await db
- .select()
- .from(chatExternalPrincipals)
- .where(
- and(
- eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
- eq(chatExternalPrincipals.id, principalId),
- eq(chatExternalPrincipals.provider, record.endpoint.provider),
- eq(
- chatExternalPrincipals.providerAccountId,
- record.endpoint.providerAccountId ?? "",
- ),
- ),
- )
- .then((rows) => rows[0] ?? null);
- if (!principal) throw notFound("External identity not found");
- if (principal.isBot || principal.kind !== "user") {
- throw unprocessable("Only a human external identity can be linked");
- }
- const token = randomBytes(32).toString("base64url");
- const tokenHash = createHash("sha256").update(token).digest("hex");
- const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
- await db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principalId}`}, 0))`,
- );
- const existingLink = await tx
- .select({ status: chatIdentityLinks.status })
- .from(chatIdentityLinks)
- .where(
- and(
- eq(chatIdentityLinks.endpointId, endpointId),
- eq(chatIdentityLinks.principalId, principalId),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- if (existingLink?.status === "linked") {
- throw conflict("This external identity is already linked", {
- code: "chat_identity_already_linked",
- });
- }
- await tx
- .insert(chatIdentityLinks)
- .values({
- companyId: record.endpoint.companyId,
- endpointId,
- principalId,
- status: "pending",
- confirmationTokenHash: tokenHash,
- expiresAt,
- })
- .onConflictDoUpdate({
- target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId],
- set: {
- paperclipUserId: null,
- status: "pending",
- confirmationTokenHash: tokenHash,
- expiresAt,
- confirmedAt: null,
- revokedAt: null,
- updatedAt: new Date(),
- },
- });
- });
- const path = `/chat-identity/confirm?token=${encodeURIComponent(token)}`;
- return {
- confirmationUrl: getPublicBaseUrl() ? `${getPublicBaseUrl()}${path}` : path,
- expiresAt: expiresAt.toISOString(),
- };
- }
-
- async function previewIdentityLink(token: string, userId?: string | null) {
- const tokenHash = createHash("sha256").update(token).digest("hex");
- const row = await db
- .select({
- link: chatIdentityLinks,
- principal: chatExternalPrincipals,
- endpoint: chatEndpoints,
- companyName: companies.name,
- companyPrefix: companies.issuePrefix,
- })
- .from(chatIdentityLinks)
- .innerJoin(
- chatExternalPrincipals,
- and(
- eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId),
- eq(chatExternalPrincipals.id, chatIdentityLinks.principalId),
- ),
- )
- .innerJoin(
- chatEndpoints,
- and(
- eq(chatEndpoints.companyId, chatIdentityLinks.companyId),
- eq(chatEndpoints.id, chatIdentityLinks.endpointId),
- ),
- )
- .innerJoin(companies, eq(companies.id, chatIdentityLinks.companyId))
- .where(
- and(
- eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
- eq(chatIdentityLinks.status, "pending"),
- ),
- )
- .then((rows) => rows[0] ?? null);
- if (!row || !row.link.expiresAt || row.link.expiresAt <= new Date()) {
- throw unprocessable("This identity-link request is invalid or expired");
- }
- if (!["active", "verifying"].includes(row.endpoint.status)) throw unprocessable("This connection is not available");
- const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and(
- eq(chatActions.companyId, row.link.companyId), eq(chatActions.endpointId, row.link.endpointId),
- eq(chatActions.principalId, row.link.principalId), eq(chatActions.kind, "slack_connect"),
- sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`,
- )).limit(1).then((rows) => rows[0]));
- const membership = userId ? await db.select({ status: companyMemberships.status }).from(companyMemberships).where(and(
- eq(companyMemberships.companyId, row.link.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId),
- )).then((rows) => rows[0]) : null;
- return {
- selfService,
- canConfirm: membership?.status === "active",
- endpointId: row.endpoint.id,
- companyId: row.endpoint.companyId,
- companyName: row.companyName,
- companyPrefix: row.companyPrefix,
- provider: row.endpoint.provider,
- providerAccountLabel: row.endpoint.providerAccountLabel,
- botLabel: row.endpoint.botDisplayName,
- externalLabel:
- row.principal.displayName ??
- row.principal.handle ??
- row.principal.externalId,
- externalDetail: row.principal.handle
- ? `@${row.principal.handle.replace(/^@/, "")}`
- : row.principal.externalId,
- expiresAt: row.link.expiresAt.toISOString(),
- };
- }
-
- async function requestIdentityAccess(token: string, userId: string, requestIp: string) {
- const preview = await previewIdentityLink(token, userId);
- if (!preview.selfService) throw forbidden("This identity link cannot request access");
- if (preview.canConfirm) return { status: "member" as const };
- return db.transaction(async (tx) => {
- await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`);
- const tokenHash = createHash("sha256").update(token).digest("hex");
- const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and(
- eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId),
- eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()),
- )).for("update").then((rows) => rows[0]);
- if (!validLink) throw unprocessable("This identity-link request is invalid or expired");
- const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]);
- if (!user) throw forbidden("Sign in to request company access");
- const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and(
- eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"),
- or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`),
- )).then((rows) => rows[0]);
- if (existing) return { status: "pending_approval" as const };
- const now = new Date();
- const [invite] = await tx.insert(invites).values({
- companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"),
- inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now,
- defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" },
- }).returning({ id: invites.id });
- const [request] = await tx.insert(joinRequests).values({
- inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval",
- requestIp, requestingUserId: userId, requestEmailSnapshot: user.email,
- }).returning({ id: joinRequests.id });
- await logActivity(tx as unknown as Db, {
- companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested",
- entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId },
- });
- return { status: "pending_approval" as const };
- });
- }
-
- async function confirmIdentityLink(token: string, paperclipUserId: string) {
- const tokenHash = createHash("sha256").update(token).digest("hex");
- const link = await db
- .select()
- .from(chatIdentityLinks)
- .where(
- and(
- eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
- eq(chatIdentityLinks.status, "pending"),
- ),
- )
- .then((rows) => rows[0] ?? null);
- if (!link || !link.expiresAt || link.expiresAt <= new Date())
- throw unprocessable("This identity-link request is invalid or expired");
- const endpointRecordForLink = await endpointRecord(link.endpointId);
- if (!endpointRecordForLink || !["active", "verifying"].includes(endpointRecordForLink.endpoint.status)) throw unprocessable("This connection is not available");
- const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await db.select({ payload: chatActions.payload }).from(chatActions).where(and(
- eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
- )).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]) : null;
- const confirmationRuntime = connectReceipt ? runtimeContexts.get((await runtimeFor(endpointRecordForLink.endpoint)) as object) : null;
- let confirmationEffectId: string | null = null;
- const confirmed = await db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${link.principalId}`}, 0))`,
- );
- const currentLink = await tx
- .select({
- id: chatIdentityLinks.id,
- companyId: chatIdentityLinks.companyId,
- endpointId: chatIdentityLinks.endpointId,
- principalId: chatIdentityLinks.principalId,
- expiresAt: chatIdentityLinks.expiresAt,
- })
- .from(chatIdentityLinks)
- .where(
- and(
- eq(chatIdentityLinks.id, link.id),
- eq(chatIdentityLinks.status, "pending"),
- eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- const now = new Date();
- if (
- !currentLink ||
- !currentLink.expiresAt ||
- currentLink.expiresAt <= now
- ) {
- return null;
- }
- const membership = await tx
- .select({ status: companyMemberships.status })
- .from(companyMemberships)
- .where(
- and(
- eq(companyMemberships.companyId, currentLink.companyId),
- eq(companyMemberships.principalType, "user"),
- eq(companyMemberships.principalId, paperclipUserId),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- if (membership?.status !== "active") {
- throw forbidden(
- "The signed-in Paperclip account is not a member of this company",
- );
- }
- const conflictingLink = await tx
- .select({
- id: chatIdentityLinks.id,
- paperclipUserId: chatIdentityLinks.paperclipUserId,
- })
- .from(chatIdentityLinks)
- .where(
- and(
- eq(chatIdentityLinks.companyId, link.companyId),
- eq(chatIdentityLinks.principalId, link.principalId),
- eq(chatIdentityLinks.status, "linked"),
- ne(chatIdentityLinks.paperclipUserId, paperclipUserId),
- ),
- )
- .limit(1)
- .then((rows) => rows[0] ?? null);
- if (conflictingLink) {
- throw conflict(
- "This provider identity is linked to a different Paperclip account",
- {
- code: "chat_identity_link_conflict",
- },
- );
- }
- const confirmedLink = await tx
- .update(chatIdentityLinks)
- .set({
- paperclipUserId,
- status: "linked",
- confirmationTokenHash: null,
- confirmedAt: now,
- updatedAt: now,
- })
- .where(
- and(
- eq(chatIdentityLinks.id, link.id),
- eq(chatIdentityLinks.status, "pending"),
- eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
- gt(chatIdentityLinks.expiresAt, now),
- ),
- )
- .returning({ endpointId: chatIdentityLinks.endpointId })
- .then((rows) => rows[0] ?? null);
- if (confirmedLink && connectReceipt && confirmationRuntime &&
- typeof connectReceipt.payload.channelId === "string" && typeof connectReceipt.payload.userId === "string") {
- const effect = await stageProviderEffect(tx, {
- endpoint: endpointRecordForLink.endpoint, principalId: link.principalId,
- providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`,
- payload: { version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
- threadId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId,
- text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false },
- runtimeContext: confirmationRuntime,
- });
- if (!effect) throw conflict("The Slack connection changed; try confirming again");
- confirmationEffectId = effect.id;
- }
- return confirmedLink;
- });
- if (!confirmed) {
- throw conflict("This identity-link request was already used or expired", {
- code: "chat_identity_link_consumed",
- });
- }
- if (confirmationEffectId) scheduleProviderEffect(confirmationEffectId);
- return { ok: true, endpointId: confirmed.endpointId };
- }
-
- async function revokeLink(endpointId: string, principalId: string) {
- const link = await db
- .select({ companyId: chatIdentityLinks.companyId })
- .from(chatIdentityLinks)
- .where(
- and(
- eq(chatIdentityLinks.endpointId, endpointId),
- eq(chatIdentityLinks.principalId, principalId),
- ),
- )
- .then((rows) => rows[0] ?? null);
- if (!link) throw notFound("Identity link not found");
- const revoked = await db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${principalId}`}, 0))`,
- );
- return tx
- .update(chatIdentityLinks)
- .set({
- paperclipUserId: null,
- status: "revoked",
- confirmationTokenHash: null,
- revokedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(chatIdentityLinks.companyId, link.companyId),
- eq(chatIdentityLinks.endpointId, endpointId),
- eq(chatIdentityLinks.principalId, principalId),
- ),
- )
- .returning({ id: chatIdentityLinks.id })
- .then((rows) => rows[0] ?? null);
- });
- if (!revoked) throw notFound("Identity link not found");
- }
-
async function listConversations(endpointId: string) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
diff --git a/server/src/services/chat-channels/github/identity-links.ts b/server/src/services/chat-channels/github/identity-links.ts
new file mode 100644
index 00000000000..c5a60c87e83
--- /dev/null
+++ b/server/src/services/chat-channels/github/identity-links.ts
@@ -0,0 +1,7 @@
+import type { chatExternalPrincipals } from "@paperclipai/db";
+
+export function githubIdentityFields(
+ principal: Pick,
+) {
+ return { githubUserId: principal.externalId, githubLogin: principal.handle };
+}
diff --git a/server/src/services/chat-channels/identity-links.ts b/server/src/services/chat-channels/identity-links.ts
new file mode 100644
index 00000000000..47596405f3a
--- /dev/null
+++ b/server/src/services/chat-channels/identity-links.ts
@@ -0,0 +1,397 @@
+import { githubIdentityFields } from "./github/identity-links.js";
+import { createSlackIdentityLinks, type SlackIdentityEffectStager } from "./slack/identity-links.js";
+import { createHash, randomBytes } from "node:crypto";
+import { and, asc, eq, gt, inArray, ne, sql } from "drizzle-orm";
+
+import type { Db } from "@paperclipai/db";
+
+import { authUsers, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, companies, companyMemberships } from "@paperclipai/db";
+
+import { conflict, forbidden, notFound, unprocessable } from "../../errors.js";
+
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
+
+type ChatIdentityLinkOptions = {
+ endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
+ getPublicBaseUrl: () => string | null;
+ confirmationRuntimeFor: (endpoint: EndpointRow) => Promise;
+ stageProviderEffect: SlackIdentityEffectStager;
+ scheduleProviderEffect: (actionId: string) => void;
+};
+
+export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions) {
+ const { endpointRecord, getPublicBaseUrl, confirmationRuntimeFor, stageProviderEffect, scheduleProviderEffect } = options;
+ const slackIdentityLinks = createSlackIdentityLinks(db, { previewIdentityLink, stageProviderEffect });
+
+ async function listPrincipals(endpointId: string) {
+ const record = await endpointRecord(endpointId);
+ if (!record) throw notFound("Chat endpoint not found");
+ const principals = await db
+ .select()
+ .from(chatExternalPrincipals)
+ .where(
+ and(
+ eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
+ eq(chatExternalPrincipals.provider, record.endpoint.provider),
+ eq(
+ chatExternalPrincipals.providerAccountId,
+ record.endpoint.providerAccountId ?? "unknown",
+ ),
+ ),
+ )
+ .orderBy(asc(chatExternalPrincipals.displayName));
+ const links = await db
+ .select()
+ .from(chatIdentityLinks)
+ .where(eq(chatIdentityLinks.endpointId, endpointId));
+ const connects = record.endpoint.provider === "slack" ? await slackIdentityLinks.listConnects(record.endpoint) : [];
+ const connectByPrincipal = new Map(connects.map((row) => [row.principalId, new Date(row.lastConnectAt).toISOString()]));
+ const userIds = links.flatMap((link) =>
+ link.paperclipUserId ? [link.paperclipUserId] : [],
+ );
+ const users = userIds.length
+ ? await db
+ .select({
+ id: authUsers.id,
+ name: authUsers.name,
+ email: authUsers.email,
+ })
+ .from(authUsers)
+ .where(inArray(authUsers.id, userIds))
+ : [];
+ const linkByPrincipal = new Map(
+ links.map((link) => [link.principalId, link]),
+ );
+ const userById = new Map(users.map((user) => [user.id, user]));
+ return principals.map((principal) => {
+ const link = linkByPrincipal.get(principal.id);
+ const user = link?.paperclipUserId
+ ? userById.get(link.paperclipUserId)
+ : null;
+ return {
+ id: link?.id ?? principal.id,
+ principalId: principal.id,
+ ...(record.endpoint.provider === "github" ? githubIdentityFields(principal) : {}),
+ externalLabel:
+ principal.displayName ?? principal.handle ?? principal.externalId,
+ externalDetail: principal.handle
+ ? `@${principal.handle.replace(/^@/, "")}`
+ : principal.externalId,
+ paperclipUserId: link?.paperclipUserId ?? null,
+ paperclipUserLabel: user?.name ?? user?.email ?? null,
+ status: link?.status ?? "pending",
+ lastConnectAt: connectByPrincipal.get(principal.id) ?? null,
+ };
+ });
+ }
+
+ async function createLinkIntent(
+ endpointId: string,
+ principalId: string,
+ expiresInSeconds: number,
+ ) {
+ const record = await endpointRecord(endpointId);
+ if (!record) throw notFound("Chat endpoint not found");
+ const principal = await db
+ .select()
+ .from(chatExternalPrincipals)
+ .where(
+ and(
+ eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
+ eq(chatExternalPrincipals.id, principalId),
+ eq(chatExternalPrincipals.provider, record.endpoint.provider),
+ eq(
+ chatExternalPrincipals.providerAccountId,
+ record.endpoint.providerAccountId ?? "",
+ ),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ if (!principal) throw notFound("External identity not found");
+ if (principal.isBot || principal.kind !== "user") {
+ throw unprocessable("Only a human external identity can be linked");
+ }
+ const token = randomBytes(32).toString("base64url");
+ const tokenHash = createHash("sha256").update(token).digest("hex");
+ const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
+ await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principalId}`}, 0))`,
+ );
+ const existingLink = await tx
+ .select({ status: chatIdentityLinks.status })
+ .from(chatIdentityLinks)
+ .where(
+ and(
+ eq(chatIdentityLinks.endpointId, endpointId),
+ eq(chatIdentityLinks.principalId, principalId),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (existingLink?.status === "linked") {
+ throw conflict("This external identity is already linked", {
+ code: "chat_identity_already_linked",
+ });
+ }
+ await tx
+ .insert(chatIdentityLinks)
+ .values({
+ companyId: record.endpoint.companyId,
+ endpointId,
+ principalId,
+ status: "pending",
+ confirmationTokenHash: tokenHash,
+ expiresAt,
+ })
+ .onConflictDoUpdate({
+ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId],
+ set: {
+ paperclipUserId: null,
+ status: "pending",
+ confirmationTokenHash: tokenHash,
+ expiresAt,
+ confirmedAt: null,
+ revokedAt: null,
+ updatedAt: new Date(),
+ },
+ });
+ });
+ const path = `/chat-identity/confirm?token=${encodeURIComponent(token)}`;
+ return {
+ confirmationUrl: getPublicBaseUrl() ? `${getPublicBaseUrl()}${path}` : path,
+ expiresAt: expiresAt.toISOString(),
+ };
+ }
+
+ async function previewIdentityLink(token: string, userId?: string | null) {
+ const tokenHash = createHash("sha256").update(token).digest("hex");
+ const row = await db
+ .select({
+ link: chatIdentityLinks,
+ principal: chatExternalPrincipals,
+ endpoint: chatEndpoints,
+ companyName: companies.name,
+ companyPrefix: companies.issuePrefix,
+ })
+ .from(chatIdentityLinks)
+ .innerJoin(
+ chatExternalPrincipals,
+ and(
+ eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId),
+ eq(chatExternalPrincipals.id, chatIdentityLinks.principalId),
+ ),
+ )
+ .innerJoin(
+ chatEndpoints,
+ and(
+ eq(chatEndpoints.companyId, chatIdentityLinks.companyId),
+ eq(chatEndpoints.id, chatIdentityLinks.endpointId),
+ ),
+ )
+ .innerJoin(companies, eq(companies.id, chatIdentityLinks.companyId))
+ .where(
+ and(
+ eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
+ eq(chatIdentityLinks.status, "pending"),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ if (!row || !row.link.expiresAt || row.link.expiresAt <= new Date()) {
+ throw unprocessable("This identity-link request is invalid or expired");
+ }
+ if (!["active", "verifying"].includes(row.endpoint.status)) throw unprocessable("This connection is not available");
+ const selfService = await slackIdentityLinks.isSelfService(row.link, tokenHash);
+ const membership = userId ? await db.select({ status: companyMemberships.status }).from(companyMemberships).where(and(
+ eq(companyMemberships.companyId, row.link.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId),
+ )).then((rows) => rows[0]) : null;
+ return {
+ selfService,
+ canConfirm: membership?.status === "active",
+ endpointId: row.endpoint.id,
+ companyId: row.endpoint.companyId,
+ companyName: row.companyName,
+ companyPrefix: row.companyPrefix,
+ provider: row.endpoint.provider,
+ providerAccountLabel: row.endpoint.providerAccountLabel,
+ botLabel: row.endpoint.botDisplayName,
+ externalLabel:
+ row.principal.displayName ??
+ row.principal.handle ??
+ row.principal.externalId,
+ externalDetail: row.principal.handle
+ ? `@${row.principal.handle.replace(/^@/, "")}`
+ : row.principal.externalId,
+ expiresAt: row.link.expiresAt.toISOString(),
+ };
+ }
+
+ async function confirmIdentityLink(token: string, paperclipUserId: string) {
+ const tokenHash = createHash("sha256").update(token).digest("hex");
+ const link = await db
+ .select()
+ .from(chatIdentityLinks)
+ .where(
+ and(
+ eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
+ eq(chatIdentityLinks.status, "pending"),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ if (!link || !link.expiresAt || link.expiresAt <= new Date())
+ throw unprocessable("This identity-link request is invalid or expired");
+ const endpointRecordForLink = await endpointRecord(link.endpointId);
+ if (!endpointRecordForLink || !["active", "verifying"].includes(endpointRecordForLink.endpoint.status)) throw unprocessable("This connection is not available");
+ const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await slackIdentityLinks.latestConnect(link) : null;
+ const confirmationRuntime = connectReceipt ? await confirmationRuntimeFor(endpointRecordForLink.endpoint) : null;
+ let confirmationEffectId: string | null = null;
+ const confirmed = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${link.principalId}`}, 0))`,
+ );
+ const currentLink = await tx
+ .select({
+ id: chatIdentityLinks.id,
+ companyId: chatIdentityLinks.companyId,
+ endpointId: chatIdentityLinks.endpointId,
+ principalId: chatIdentityLinks.principalId,
+ expiresAt: chatIdentityLinks.expiresAt,
+ })
+ .from(chatIdentityLinks)
+ .where(
+ and(
+ eq(chatIdentityLinks.id, link.id),
+ eq(chatIdentityLinks.status, "pending"),
+ eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ const now = new Date();
+ if (
+ !currentLink ||
+ !currentLink.expiresAt ||
+ currentLink.expiresAt <= now
+ ) {
+ return null;
+ }
+ const membership = await tx
+ .select({ status: companyMemberships.status })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, currentLink.companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.principalId, paperclipUserId),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (membership?.status !== "active") {
+ throw forbidden(
+ "The signed-in Paperclip account is not a member of this company",
+ );
+ }
+ const conflictingLink = await tx
+ .select({
+ id: chatIdentityLinks.id,
+ paperclipUserId: chatIdentityLinks.paperclipUserId,
+ })
+ .from(chatIdentityLinks)
+ .where(
+ and(
+ eq(chatIdentityLinks.companyId, link.companyId),
+ eq(chatIdentityLinks.principalId, link.principalId),
+ eq(chatIdentityLinks.status, "linked"),
+ ne(chatIdentityLinks.paperclipUserId, paperclipUserId),
+ ),
+ )
+ .limit(1)
+ .then((rows) => rows[0] ?? null);
+ if (conflictingLink) {
+ throw conflict(
+ "This provider identity is linked to a different Paperclip account",
+ {
+ code: "chat_identity_link_conflict",
+ },
+ );
+ }
+ const confirmedLink = await tx
+ .update(chatIdentityLinks)
+ .set({
+ paperclipUserId,
+ status: "linked",
+ confirmationTokenHash: null,
+ confirmedAt: now,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(chatIdentityLinks.id, link.id),
+ eq(chatIdentityLinks.status, "pending"),
+ eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
+ gt(chatIdentityLinks.expiresAt, now),
+ ),
+ )
+ .returning({ endpointId: chatIdentityLinks.endpointId })
+ .then((rows) => rows[0] ?? null);
+ if (confirmedLink && connectReceipt && confirmationRuntime &&
+ typeof connectReceipt.payload.channelId === "string" && typeof connectReceipt.payload.userId === "string") {
+ confirmationEffectId = await slackIdentityLinks.stageConfirmation(tx, {
+ endpoint: endpointRecordForLink.endpoint, link, tokenHash,
+ channelId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId,
+ confirmationRuntime,
+ });
+ }
+ return confirmedLink;
+ });
+ if (!confirmed) {
+ throw conflict("This identity-link request was already used or expired", {
+ code: "chat_identity_link_consumed",
+ });
+ }
+ if (confirmationEffectId) scheduleProviderEffect(confirmationEffectId);
+ return { ok: true, endpointId: confirmed.endpointId };
+ }
+
+ async function revokeLink(endpointId: string, principalId: string) {
+ const link = await db
+ .select({ companyId: chatIdentityLinks.companyId })
+ .from(chatIdentityLinks)
+ .where(
+ and(
+ eq(chatIdentityLinks.endpointId, endpointId),
+ eq(chatIdentityLinks.principalId, principalId),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ if (!link) throw notFound("Identity link not found");
+ const revoked = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${principalId}`}, 0))`,
+ );
+ return tx
+ .update(chatIdentityLinks)
+ .set({
+ paperclipUserId: null,
+ status: "revoked",
+ confirmationTokenHash: null,
+ revokedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(chatIdentityLinks.companyId, link.companyId),
+ eq(chatIdentityLinks.endpointId, endpointId),
+ eq(chatIdentityLinks.principalId, principalId),
+ ),
+ )
+ .returning({ id: chatIdentityLinks.id })
+ .then((rows) => rows[0] ?? null);
+ });
+ if (!revoked) throw notFound("Identity link not found");
+ }
+
+ return { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess: slackIdentityLinks.requestIdentityAccess, confirmIdentityLink, revokeLink };
+}
diff --git a/server/src/services/chat-channels/slack/identity-links.ts b/server/src/services/chat-channels/slack/identity-links.ts
new file mode 100644
index 00000000000..1a37771fa01
--- /dev/null
+++ b/server/src/services/chat-channels/slack/identity-links.ts
@@ -0,0 +1,113 @@
+import { createHash, randomBytes } from "node:crypto";
+import { and, desc, eq, gt, or, sql } from "drizzle-orm";
+
+import type { Db } from "@paperclipai/db";
+
+import { authUsers, chatActions, chatEndpoints, chatIdentityLinks, invites, joinRequests } from "@paperclipai/db";
+
+import { conflict, forbidden, unprocessable } from "../../../errors.js";
+
+import { logActivity } from "../../activity-log.js";
+
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+type DbOrTransaction = Db | Parameters[0]>[0];
+type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
+export type SlackIdentityEffectStager = (database: DbOrTransaction, input: {
+ endpoint: EndpointRow; principalId: string; providerActionId: string;
+ payload: { version: 1; effect: "ephemeral_message"; authorizationMode: "safe_notice"; threadId: string; userId: string; text: string; settleDelivery: false };
+ runtimeContext: ConfirmationRuntime;
+}) => Promise<{ id: string } | null>;
+
+type SlackIdentityLinkOptions = {
+ previewIdentityLink: (token: string, userId: string) => Promise<{ selfService: boolean; canConfirm: boolean; companyId: string; endpointId: string }>;
+ stageProviderEffect: SlackIdentityEffectStager;
+};
+
+export function createSlackIdentityLinks(db: Db, { previewIdentityLink, stageProviderEffect }: SlackIdentityLinkOptions) {
+ function listConnects(endpoint: EndpointRow) {
+ const endpointId = endpoint.id;
+ return db
+ .select({ principalId: chatActions.principalId, lastConnectAt: sql`max(${chatActions.createdAt})::text` })
+ .from(chatActions)
+ .where(and(
+ eq(chatActions.companyId, endpoint.companyId),
+ eq(chatActions.endpointId, endpointId),
+ eq(chatActions.kind, "slack_connect"),
+ eq(chatActions.status, "processed"),
+ ))
+ .groupBy(chatActions.principalId);
+ }
+
+ async function isSelfService(link: typeof chatIdentityLinks.$inferSelect, tokenHash: string) {
+ const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and(
+ eq(chatActions.companyId, link.companyId), eq(chatActions.endpointId, link.endpointId),
+ eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
+ sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`,
+ )).limit(1).then((rows) => rows[0]));
+ return selfService;
+ }
+
+ function latestConnect(link: typeof chatIdentityLinks.$inferSelect) {
+ return db.select({ payload: chatActions.payload }).from(chatActions).where(and(
+ eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
+ )).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]);
+ }
+
+ async function stageConfirmation(tx: DbOrTransaction, input: {
+ endpoint: EndpointRow; link: typeof chatIdentityLinks.$inferSelect; tokenHash: string;
+ channelId: string; userId: string; confirmationRuntime: ConfirmationRuntime;
+ }) {
+ const { endpoint, link, tokenHash, channelId, userId, confirmationRuntime } = input;
+ const effect = await stageProviderEffect(tx, {
+ endpoint: endpoint, principalId: link.principalId,
+ providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`,
+ payload: {
+ version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
+ threadId: channelId, userId: userId,
+ text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false
+ },
+ runtimeContext: confirmationRuntime,
+ });
+ if (!effect) throw conflict("The Slack connection changed; try confirming again");
+ return effect.id;
+ }
+
+ async function requestIdentityAccess(token: string, userId: string, requestIp: string) {
+ const preview = await previewIdentityLink(token, userId);
+ if (!preview.selfService) throw forbidden("This identity link cannot request access");
+ if (preview.canConfirm) return { status: "member" as const };
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`);
+ const tokenHash = createHash("sha256").update(token).digest("hex");
+ const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and(
+ eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId),
+ eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()),
+ )).for("update").then((rows) => rows[0]);
+ if (!validLink) throw unprocessable("This identity-link request is invalid or expired");
+ const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]);
+ if (!user) throw forbidden("Sign in to request company access");
+ const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and(
+ eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"),
+ or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`),
+ )).then((rows) => rows[0]);
+ if (existing) return { status: "pending_approval" as const };
+ const now = new Date();
+ const [invite] = await tx.insert(invites).values({
+ companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"),
+ inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now,
+ defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" },
+ }).returning({ id: invites.id });
+ const [request] = await tx.insert(joinRequests).values({
+ inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval",
+ requestIp, requestingUserId: userId, requestEmailSnapshot: user.email,
+ }).returning({ id: joinRequests.id });
+ await logActivity(tx as unknown as Db, {
+ companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested",
+ entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId },
+ });
+ return { status: "pending_approval" as const };
+ });
+ }
+
+ return { listConnects, isSelfService, latestConnect, stageConfirmation, requestIdentityAccess };
+}
From 5a72061ccc5ce6acf5524bb5c3b1cb8d10de5147 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:49:34 -0500
Subject: [PATCH 08/23] refactor(chat): extract publication replacement
decisions (#15844)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip publishes agent progress and results back to chat.
> - Some updates replace an earlier message while others need a new
message.
> - The current outbound link and task generation determine that
authority.
> - These decisions are spread through the large chat service.
> - This PR groups publication replacement and supersession decisions in
one module.
> - Reviewers can follow those decisions without reading provider
transport code.
## Linked Issues or Issue Description
Refs #15836, #15834 and #15823. Stack slice 4 of 5, based on
`codex/chat-identity-links`.
Merge the predecessor first, then retarget this PR. Do not merge the
stack out of order.
Stack order: #15823 (provider messages) → #15834 (resource
reconciliation) → #15836 (identity links) → #15844 (publication
replacement) → #15845 (failed-run retries). GitHub native stack merging
preserves this order and automatically restacks the remaining branches.
## What Changed
- Extract shared publication replacement and supersession queries from
the large service.
- Keep exact outbound ownership, run/wake scope, consumed progress,
interaction replacement and retained-source authorization together.
- Put the iMessage typing-versus-progress policy in
`imessage-photon/publications.ts`.
- Preserve endpoint lookup timing and all ownership query bodies. The
multi-provider close allowlist remains shared dispatch metadata.
- Restack onto the revised #15836 and record why this boundary remains
mostly shared.
## Verification
- The same 49 existing publication and Photon integration cases passed
before and after the provider-policy move. Other cases were excluded by
the filter.
- Cases cover close-owned progress, ambiguous delivery, interleaved
Slack control, Telegram confirmation, FIFO replacement and Photon
channel behavior.
- Server typecheck, module-boundary and whitespace checks passed. Only
the iMessage predicate/reason moved; ownership queries are unchanged.
- Historical server shard 2 hit a cleanup deadlock; its unchanged
[isolated rerun
passed](https://github.com/paperclipai/paperclip/actions/runs/38134431077/job/114457999409).
[Original
failure](https://github.com/paperclipai/paperclip/actions/runs/38134431077/job/114455332022).
- Tests use disposable databases and fixture provider transports, not
live third-party execution.
- Fresh Greptile review is 5/5 on
`8e2da97b6dc11eb446128b61c789edb529c50d5a`, with a successful check and
no open review threads.
- Final-stack `pnpm -r typecheck` and `pnpm build` passed at
`57ec5ba94aecd860471f9b14db46f99120c7c790`.
- Historical pre-restack CI failed on `ci / General tests (server
(11/12))` and its aggregate `ci / verify`. All 1,255 tests passed, but
worker-exit cleanup in unchanged
`server/src/__tests__/setup-supertest.ts:32` failed with EACCES removing
a temporary `paperclip-vitest-home-*` directory. The [original
job](https://github.com/paperclipai/paperclip/actions/runs/38256549828/job/114836065382)
and the [single unchanged
rerun](https://github.com/paperclipai/paperclip/actions/runs/38256549828/job/114839735273)
failed the same way. No further reruns were requested. After GitHub
automatically restacked the branches, the full cumulative run on #15845
passed this server shard. This middle PR uses the repository’s
policy-only stack CI scope; the top PR runs all suites.
- The final full-local-suite outcome for this cumulative stack is
recorded in #15845; do not infer that gate from the focused tests above.
- Merge verification on the restacked tree: 118 focused identity,
publication, Photon and retry integration tests passed; server typecheck
and module-boundary checks passed. GitHub’s rebased trees exactly match
the previously reviewed file contents.
- All current-head required checks passed on
`8e2da97b6dc11eb446128b61c789edb529c50d5a`: policy CI (middle-stack
scope; full cumulative suites passed on #15845); Greptile 5/5 with no
unresolved threads.
## Risks
- Splitting shared ownership queries per channel would duplicate
authorization rules. They remain in the shared module.
- Preserve the issue-to-run lock order and retained-source callback. The
factory creates no worker or mutable state.
- This PR depends on #15836. Merge and retarget in stack order.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning and repository tools. The
exact runtime model ID and context-window size are not exposed in this
session. No separate agent was used.
## 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
- [ ] 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
---
.../2026-10-10-chat-channels-refactor.md | 30 +
server/src/services/chat-channels.ts | 906 +---------------
.../imessage-photon/publications.ts | 9 +
.../chat-channels/publication-replacement.ts | 965 ++++++++++++++++++
4 files changed, 1006 insertions(+), 904 deletions(-)
create mode 100644 server/src/services/chat-channels/imessage-photon/publications.ts
create mode 100644 server/src/services/chat-channels/publication-replacement.ts
diff --git a/doc/plans/2026-10-10-chat-channels-refactor.md b/doc/plans/2026-10-10-chat-channels-refactor.md
index ce51837f239..bba089b0c2a 100644
--- a/doc/plans/2026-10-10-chat-channels-refactor.md
+++ b/doc/plans/2026-10-10-chat-channels-refactor.md
@@ -203,3 +203,33 @@ Slack connect journey: discovery, preview, outsider access request, duplicate
request, member confirmation, provider notice, consumed-token rejection and
endpoint isolation. Receipt queries, access-request transaction and notice
payload retain their statements with explicit parameter substitutions.
+
+### Slice 4: publication replacement and supersession
+
+Move 13 functions into `publication-replacement.ts`. The module selects the exact
+provider message a publication can replace and suppresses obsolete progress. It
+uses the database, endpoint lookup and retained-source authorization callback.
+Transport execution, publication claims and receipt reactions remain coordinated
+by the service. No state or registration moves in this slice.
+
+- Source: 37,295 lines / 1,436,867 bytes → 36,393 lines / 1,400,636 bytes.
+- Destination: 963 lines / 38,724 bytes.
+- Baseline: all 51 checks and 5/5 review passed on slice 3 (`581dd656c1`, #15836).
+- All 13 bodies and every remaining statement match the predecessor exactly.
+ Server typecheck, module boundaries and whitespace checks pass.
+- Existing CI fixtures cover exact outbound ownership, interleaved control
+ messages, consumed progress, interactions, committed failure replacement and
+ issue-to-run lock order. Local database capacity remains blocked.
+
+### 2026-10-12 channel-folder reassessment: slice 4
+
+Publication replacement is shared ownership logic: exact outbound links, run and
+wake scope, consumed progress messages, interaction prompts and retained-source
+authorization. Keep those queries together. The close-message provider allowlist
+is shared dispatch metadata, not four separate implementations.
+
+Move the iMessage queued/working suppression policy and explanation to
+`imessage-photon/publications.ts`. Preserve when endpoint lookup occurs and leave
+all ownership queries unchanged. Baseline: 49 public-service publication and
+Photon cases passed, including close-owned progress, interleaved Slack control,
+Telegram confirmation and FIFO replacement. The same 49 cases passed after. Server typecheck and module boundaries pass.
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index 5930982dd0b..2e88f05b99c 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -1,3 +1,4 @@
+import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js";
import { createChatIdentityLinks } from "./chat-channels/identity-links.js";
import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js";
import { githubRepositoryInventoryItemFromPayload } from "./chat-channels/github/resources.js";
@@ -2427,6 +2428,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}),
});
const issuesSvc = issueService(db);
+ const { runIdFromMilestonePublication, runOwnershipMilestoneSupersessionReason, runPublicationToReplace, receiptReactionCompletionRunId, inboundWakePublicationToReplace, interactionResolutionPublicationToReplace, interactionPromptPublicationToReplace, progressSupersededByFastResponse, runProgressSupersededByPublishedInteraction, taskStatusPublicationToReplace, closeProgressToReplace } = createChatPublicationReplacement(db, { endpointRecord, authorizeRetainedChatSourcePublication });
const secrets = secretService(db);
const questionResponses = questionResponseDeliveryService(db, {
heartbeat:
@@ -32677,541 +32679,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
});
}
- function runIdFromMilestonePublication(
- publication: typeof chatPublications.$inferSelect,
- ): string | null {
- if (!publication.payload.progressState) return null;
- const match =
- /^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
- publication.idempotencyKey,
- );
- const runId = match?.[1] ?? null;
- // This identifier is also compared against a UUID column below. Old or
- // manually repaired rows must not be allowed to turn the global
- // publication sweep into a PostgreSQL cast error.
- return isUuidLike(runId) ? runId : null;
- }
-
- async function runOwnershipMilestoneSupersessionReason(
- publication: typeof chatPublications.$inferSelect,
- ): Promise {
- const progress = publication.payload.progressState;
- if (progress && ["queued", "working"].includes(progress)) {
- const endpoint = await endpointRecord(publication.endpointId);
- if (endpoint?.endpoint.provider === "imessage-photon") return "iMessage uses typing instead of progress bubbles";
- }
- if (
- !progress ||
- !["queued", "working", "waiting_for_input"].includes(progress)
- ) {
- return null;
- }
- const runId = runIdFromMilestonePublication(publication);
- if (!runId) return null;
- const run = await db.transaction(async (tx) => {
- // Use native finalization's issue -> run lock order. If terminalization
- // won, the current status suppresses this obsolete provider update. If
- // this short authorization snapshot wins, the progress was still true
- // at its durable send boundary and the later final remains authoritative.
- const currentIssue = await tx
- .select({ id: issues.id })
- .from(issues)
- .where(
- and(
- eq(issues.id, publication.issueId),
- eq(issues.companyId, publication.companyId),
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- if (!currentIssue) return null;
- return tx
- .select({
- status: heartbeatRuns.status,
- errorCode: heartbeatRuns.errorCode,
- })
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, runId),
- eq(heartbeatRuns.companyId, publication.companyId),
- sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${publication.issueId}`,
- ),
- )
- .for("update")
- .then((rows) => rows[0] ?? null);
- });
- const baseProgressKey = `run:${runId}:${progress}:${publication.endpointId}`;
- const nativeProgressPrefix = `run:${runId}:working:${publication.endpointId}:native:`;
- const isTerminalizableProgress =
- (progress === "queued" || progress === "working") &&
- (publication.idempotencyKey === baseProgressKey ||
- publication.idempotencyKey.startsWith(nativeProgressPrefix));
- if (
- isTerminalizableProgress &&
- run &&
- ["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(
- run.status,
- )
- ) {
- return "Run reached a terminal state before progress delivery";
- }
- // Match only the closed ownership-attention projection. Historical progress
- // without an extant run retains its existing delivery semantics.
- const ownershipBlocked =
- run?.status === "running" &&
- [
- "native_execution_ownership_unverified",
- "native_adopted_runner_authentication_timeout",
- ].includes(run.errorCode ?? "");
- if (progress === "waiting_for_input") {
- return ownershipBlocked
- ? null
- : "Run ownership attention no longer applies to the bound task";
- }
- return ownershipBlocked
- ? "Run progress was superseded by ownership recovery attention"
- : null;
- }
-
- async function providerProgressLaneConsumed(
- publication: typeof chatPublications.$inferSelect,
- providerMessageId: string,
- ): Promise {
- // Published progress rows retain their old provider ID after an edit.
- // Consult that exact message's current outbound link, not the conversation
- // tail: an authored answer or failure has consumed the lane, while an
- // interleaved task-status update may still be replaced normally.
- const [consumed] = await db
- .select({ id: chatPublications.id })
- .from(chatMessageLinks)
- .innerJoin(
- chatPublications,
- and(
- eq(chatPublications.id, chatMessageLinks.publicationId),
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(chatPublications.providerMessageId, providerMessageId),
- eq(chatPublications.state, "published"),
- ),
- )
- .where(
- and(
- eq(chatMessageLinks.companyId, publication.companyId),
- eq(chatMessageLinks.endpointId, publication.endpointId),
- eq(chatMessageLinks.conversationId, publication.conversationId),
- eq(chatMessageLinks.direction, "outbound"),
- eq(chatMessageLinks.providerMessageId, providerMessageId),
- or(
- and(
- isNotNull(chatPublications.commentId),
- sql`${chatPublications.payload}->>'progressState' is null`,
- ),
- sql`${chatPublications.payload}->>'progressState' = 'failed'`,
- ),
- ),
- )
- .limit(1);
- return Boolean(consumed);
- }
-
- async function committedResponseMayReplaceFailure(
- publication: typeof chatPublications.$inferSelect,
- runId: string,
- providerMessageId: string,
- ): Promise {
- if (
- !publication.commentId ||
- publication.payload.progressState !== undefined
- )
- return false;
- return db
- .transaction(async (tx) => {
- const [run] = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, runId),
- eq(heartbeatRuns.companyId, publication.companyId),
- eq(heartbeatRuns.nativeIssueId, publication.issueId),
- eq(heartbeatRuns.runtimeMode, "native"),
- eq(heartbeatRuns.status, "succeeded"),
- isNotNull(heartbeatRuns.finishedAt),
- ),
- )
- .for("share", { noWait: true });
- const presentation = run?.resultJson?.presentationDecision as
- Record | undefined;
- if (
- !run?.resultJson?.nativeCommittedChatResponse ||
- presentation?.commentId !== publication.commentId
- )
- return false;
- const [comment] = await tx
- .select({ id: issueComments.id })
- .from(issueComments)
- .where(
- and(
- eq(issueComments.id, publication.commentId!),
- eq(issueComments.companyId, publication.companyId),
- eq(issueComments.issueId, publication.issueId),
- eq(issueComments.createdByRunId, runId),
- eq(issueComments.authorAgentId, run.agentId),
- isNull(issueComments.deletedAt),
- ),
- )
- .for("share", { noWait: true });
- if (!comment) return false;
- const [priorAnswer] = await tx
- .select({ id: chatPublications.id })
- .from(chatPublications)
- .innerJoin(
- issueComments,
- and(
- eq(issueComments.id, chatPublications.commentId),
- eq(issueComments.companyId, publication.companyId),
- eq(issueComments.createdByRunId, runId),
- ),
- )
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- ne(chatPublications.id, publication.id),
- inArray(chatPublications.state, [
- "published",
- "streaming",
- "delivery_unknown",
- ]),
- sql`${chatPublications.payload}->>'progressState' is null`,
- ),
- )
- .limit(1);
- if (priorAnswer) return false;
- // A historical progress row can retain this ID after a later edit.
- // Only its CURRENT outbound link may grant this narrow exception; an
- // authored answer or another run's failure must remain consumed.
- const [failure] = await tx
- .select({ id: chatPublications.id })
- .from(chatMessageLinks)
- .innerJoin(
- chatPublications,
- and(
- eq(chatPublications.id, chatMessageLinks.publicationId),
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(chatPublications.providerMessageId, providerMessageId),
- eq(
- chatPublications.idempotencyKey,
- `run:${runId}:failed:${publication.endpointId}`,
- ),
- eq(chatPublications.state, "published"),
- isNull(chatPublications.commentId),
- sql`${chatPublications.payload}->>'progressState' = 'failed'`,
- ),
- )
- .where(
- and(
- eq(chatMessageLinks.companyId, publication.companyId),
- eq(chatMessageLinks.endpointId, publication.endpointId),
- eq(chatMessageLinks.conversationId, publication.conversationId),
- eq(chatMessageLinks.providerMessageId, providerMessageId),
- eq(chatMessageLinks.direction, "outbound"),
- ),
- );
- if (!failure) return false;
- // The marker is read from the authoritative run, never the publication
- // payload. Recheck accepted result/decision plus the complete current
- // source batch, actor, access and epoch before borrowing its failure lane.
- // The transport claim repeats those checks at the I/O boundary.
- return authorizeRetainedChatSourcePublication(tx, publication);
- })
- .catch((error: unknown) => {
- if (isExternalChatWaitAuthorizationContention(error))
- throw new NativeChatReviewPresentationContentionError();
- throw error;
- });
- }
-
- async function runPublicationToReplace(
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (payload.attachmentIds?.length) return null;
- const currentRunId =
- runIdFromMilestonePublication(publication) ??
- (publication.commentId
- ? await db
- .select({ runId: issueComments.createdByRunId })
- .from(issueComments)
- .where(eq(issueComments.id, publication.commentId))
- .then((rows) => rows[0]?.runId ?? null)
- : null);
- if (!currentRunId) return null;
- return db
- .select({
- id: chatPublications.id,
- commentId: chatPublications.commentId,
- providerMessageId: chatPublications.providerMessageId,
- payload: chatPublications.payload,
- })
- .from(chatPublications)
- .leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.state, "published"),
- isNotNull(chatPublications.providerMessageId),
- or(
- like(chatPublications.idempotencyKey, `run:${currentRunId}:%`),
- eq(issueComments.createdByRunId, currentRunId),
- ),
- ),
- )
- .orderBy(desc(chatPublications.createdAt), desc(chatPublications.id))
- .then(async (rows) => {
- // Progress updates are one replaceable provider-message lane per run.
- // The first durable agent comment may turn that placeholder into the
- // terminal response, but later comments from the same run are distinct
- // user-visible outputs and must be posted separately. Re-editing the
- // placeholder for each comment silently erases the earlier replies.
- if (
- publication.commentId &&
- rows.some(
- (row) =>
- row.commentId !== null && row.payload.progressState === undefined,
- )
- )
- return null;
- const replacement = rows.find(
- (row) =>
- Boolean(row.providerMessageId) &&
- row.payload.progressState !== undefined,
- );
- if (!replacement?.providerMessageId) return null;
- if (
- (await providerProgressLaneConsumed(
- publication,
- replacement.providerMessageId,
- )) &&
- !(await committedResponseMayReplaceFailure(
- publication,
- currentRunId,
- replacement.providerMessageId,
- ))
- )
- return null;
- // Replacement identity belongs to the run, not to the provider-visible
- // tail. A status/control reply may legitimately interleave while the run
- // is active; making the tail the edit candidate would strand this run's
- // working placeholder forever. The query is bounded by endpoint,
- // conversation (the task generation), and run id, so an interleaved
- // control or another run can never donate its provider message here.
- return replacement.providerMessageId;
- });
- }
-
- async function receiptReactionCompletionRunId(
- tx: Db,
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (
- payload.progressState === "completed" ||
- payload.progressState === "failed"
- ) {
- return runIdFromMilestonePublication(publication);
- }
- if (publication.commentId) {
- const runId = await tx
- .select({ runId: issueComments.createdByRunId })
- .from(issueComments)
- .where(
- and(
- eq(issueComments.id, publication.commentId),
- eq(issueComments.companyId, publication.companyId),
- ),
- )
- .then((rows) => rows[0]?.runId ?? null);
- return isUuidLike(runId) ? runId : null;
- }
- if (
- payload.interactionId &&
- publication.idempotencyKey ===
- `interaction:${payload.interactionId}:${publication.endpointId}`
- ) {
- const runId = await tx
- .select({ runId: issueThreadInteractions.sourceRunId })
- .from(issueThreadInteractions)
- .where(
- and(
- eq(issueThreadInteractions.id, payload.interactionId),
- eq(issueThreadInteractions.companyId, publication.companyId),
- eq(issueThreadInteractions.issueId, publication.issueId),
- ),
- )
- .then((rows) => rows[0]?.runId ?? null);
- return isUuidLike(runId) ? runId : null;
- }
- return null;
- }
-
- async function inboundWakePublicationToReplace(
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (payload.attachmentIds?.length) return null;
- const notice = parseInboundWakePublicationKey(publication.idempotencyKey);
- let wakeId: string | null =
- notice && notice.state !== "queued" ? notice.wakeId : null;
- let runId = runIdFromMilestonePublication(publication);
- if (
- !notice &&
- !runId &&
- payload.interactionId &&
- publication.idempotencyKey ===
- `interaction:${payload.interactionId}:${publication.endpointId}`
- ) {
- const [interaction] = await db
- .select({ runId: issueThreadInteractions.sourceRunId })
- .from(issueThreadInteractions)
- .where(
- and(
- eq(issueThreadInteractions.id, payload.interactionId),
- eq(issueThreadInteractions.companyId, publication.companyId),
- eq(issueThreadInteractions.issueId, publication.issueId),
- inArray(issueThreadInteractions.kind, [
- "ask_user_questions",
- "request_confirmation",
- ]),
- ),
- );
- runId = interaction?.runId ?? null;
- }
- if (!notice && !runId && publication.commentId) {
- const [comment] = await db
- .select({ runId: issueComments.createdByRunId })
- .from(issueComments)
- .where(
- and(
- eq(issueComments.id, publication.commentId),
- eq(issueComments.companyId, publication.companyId),
- eq(issueComments.issueId, publication.issueId),
- ),
- );
- runId = comment?.runId ?? null;
- }
- let runContext: Record | null = null;
- if (runId) {
- if (publication.commentId) {
- const [priorAnswer] = await db
- .select({ id: chatPublications.id })
- .from(chatPublications)
- .innerJoin(
- issueComments,
- eq(issueComments.id, chatPublications.commentId),
- )
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.state, "published"),
- eq(issueComments.createdByRunId, runId),
- sql`${chatPublications.payload}->>'progressState' is null`,
- ),
- )
- .limit(1);
- if (priorAnswer) return null;
- }
- const [run] = await db
- .select({
- wakeId: heartbeatRuns.wakeupRequestId,
- context: heartbeatRuns.contextSnapshot,
- })
- .from(heartbeatRuns)
- .innerJoin(
- agentWakeupRequests,
- and(
- eq(agentWakeupRequests.id, heartbeatRuns.wakeupRequestId),
- eq(agentWakeupRequests.runId, heartbeatRuns.id),
- eq(agentWakeupRequests.companyId, heartbeatRuns.companyId),
- eq(agentWakeupRequests.agentId, heartbeatRuns.agentId),
- // Failed/cancelled is the admitted run's outcome, not loss of
- // its original admission. Its terminal milestone still owns
- // this exact lane even if no working update was sent first.
- ne(agentWakeupRequests.status, "skipped"),
- ),
- )
- .where(
- and(
- eq(heartbeatRuns.id, runId),
- eq(heartbeatRuns.companyId, publication.companyId),
- sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
- ),
- );
- wakeId = run?.wakeId ?? null;
- runContext = run?.context ?? null;
- }
- if (!wakeId) return null;
- const [queued] = await db
- .select({
- commentId: chatPublications.commentId,
- providerMessageId: chatPublications.providerMessageId,
- })
- .from(chatPublications)
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(chatPublications.state, "published"),
- isNotNull(chatPublications.providerMessageId),
- eq(
- chatPublications.idempotencyKey,
- inboundWakePublicationKey(
- wakeId,
- "queued",
- publication.endpointId,
- publication.conversationId,
- ),
- ),
- ),
- );
- if (!queued?.commentId || !queued.providerMessageId) return null;
- // The run helper may have rejected this same provider ID because its
- // queued/working placeholder is already an answer or terminal failure.
- // Falling back to the older wake row must not erase that newer output.
- if (
- await providerProgressLaneConsumed(publication, queued.providerMessageId)
- )
- return null;
- if (notice && notice.state !== "queued")
- return queued.commentId === publication.commentId
- ? queued.providerMessageId
- : null;
- // A deferred owner can be a Board wake into which the exact external
- // comment coalesced. The run must actually contain that source comment;
- // wake ID alone cannot authorize a different successor's edit lane.
- return runContext &&
- (runContext.wakeCommentId === queued.commentId ||
- runContext.commentId === queued.commentId ||
- (Array.isArray(runContext.wakeCommentIds) &&
- runContext.wakeCommentIds.includes(queued.commentId)))
- ? queued.providerMessageId
- : null;
- }
-
async function stageTerminalReceiptReactionRemovals(
tx: Db,
input: {
@@ -33236,375 +32703,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
});
}
- async function interactionResolutionPublicationToReplace(
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (
- !publication.idempotencyKey.startsWith("interaction-resolution:") ||
- !payload.interactionId
- )
- return null;
- return db
- .select({ providerMessageId: chatPublications.providerMessageId })
- .from(chatPublications)
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(
- chatPublications.idempotencyKey,
- `interaction:${payload.interactionId}:${publication.endpointId}`,
- ),
- eq(chatPublications.state, "published"),
- isNotNull(chatPublications.providerMessageId),
- ),
- )
- .then((rows) => rows[0]?.providerMessageId ?? null);
- }
-
- async function interactionPromptPublicationToReplace(
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (
- !payload.interactionId ||
- publication.idempotencyKey !==
- `interaction:${payload.interactionId}:${publication.endpointId}`
- ) {
- return null;
- }
- const interaction = await db
- .select({
- kind: issueThreadInteractions.kind,
- sourceRunId: issueThreadInteractions.sourceRunId,
- })
- .from(issueThreadInteractions)
- .where(
- and(
- eq(issueThreadInteractions.id, payload.interactionId),
- eq(issueThreadInteractions.companyId, publication.companyId),
- eq(issueThreadInteractions.issueId, publication.issueId),
- inArray(issueThreadInteractions.kind, [
- "ask_user_questions",
- "request_confirmation",
- ]),
- ),
- )
- .then((rows) => rows[0] ?? null);
- if (!interaction?.sourceRunId) return null;
-
- // A provider-visible interaction is this run's response, not an additional
- // message beside its progress indicator. Move the durable interaction
- // publication onto the exact source run's provider-message lane. This
- // retires both the normal working placeholder and the queued placeholder
- // when a very fast run asks before the working update is published.
- for (const progressState of ["working", "queued"] as const) {
- const providerMessageId = await db
- .select({ providerMessageId: chatPublications.providerMessageId })
- .from(chatPublications)
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(
- chatPublications.idempotencyKey,
- `run:${interaction.sourceRunId}:${progressState}:${publication.endpointId}`,
- ),
- eq(chatPublications.state, "published"),
- isNotNull(chatPublications.providerMessageId),
- ),
- )
- .then((rows) => rows[0]?.providerMessageId ?? null);
- if (providerMessageId) return providerMessageId;
- }
- return null;
- }
-
- async function progressSupersededByFastResponse(publication: typeof chatPublications.$inferSelect): Promise {
- if (!["queued", "working"].includes(publication.payload.progressState ?? "")) return false;
- const runId = runIdFromMilestonePublication(publication);
- const [run] = runId ? await db.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, publication.companyId), eq(heartbeatRuns.id, runId))) : [];
- const sourceCommentId = typeof run?.context?.wakeCommentId === "string" ? run.context.wakeCommentId : typeof run?.context?.commentId === "string" ? run.context.commentId : publication.commentId;
- if (!sourceCommentId) return false;
- const [receipt] = await db.select({ id: fastResponseRequests.id }).from(fastResponseRequests).innerJoin(chatPublications,
- and(eq(chatPublications.companyId, publication.companyId), eq(chatPublications.commentId, fastResponseRequests.commentId), eq(chatPublications.state, "published")))
- .where(and(eq(fastResponseRequests.companyId, publication.companyId), eq(fastResponseRequests.sourceCommentId, sourceCommentId), eq(fastResponseRequests.endpointId, publication.endpointId), eq(fastResponseRequests.conversationId, publication.conversationId))).limit(1);
- return Boolean(receipt);
- }
-
- async function runProgressSupersededByPublishedInteraction(
- publication: typeof chatPublications.$inferSelect,
- ): Promise {
- if (
- !["queued", "working"].includes(publication.payload.progressState ?? "")
- ) {
- return false;
- }
- const sourceRunId = runIdFromMilestonePublication(publication);
- if (!sourceRunId) return false;
- const interactionIds = await db
- .select({ id: issueThreadInteractions.id })
- .from(issueThreadInteractions)
- .where(
- and(
- eq(issueThreadInteractions.companyId, publication.companyId),
- eq(issueThreadInteractions.issueId, publication.issueId),
- eq(issueThreadInteractions.sourceRunId, sourceRunId),
- inArray(issueThreadInteractions.kind, [
- "ask_user_questions",
- "request_confirmation",
- ]),
- ),
- )
- .then((rows) => rows.map((row) => row.id));
- if (interactionIds.length === 0) return false;
- return db
- .select({ id: chatPublications.id })
- .from(chatPublications)
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(chatPublications.state, "published"),
- inArray(
- chatPublications.idempotencyKey,
- interactionIds.map(
- (interactionId) =>
- `interaction:${interactionId}:${publication.endpointId}`,
- ),
- ),
- ),
- )
- .limit(1)
- .then((rows) => rows.length > 0);
- }
-
- async function taskStatusPublicationToReplace(
- publication: typeof chatPublications.$inferSelect,
- payload: SafeChatPublicationPayload,
- ): Promise {
- if (
- !publication.idempotencyKey.startsWith("control:status:") ||
- payload.attachmentIds?.length
- )
- return null;
- const rows = await db
- .select({
- commentId: chatPublications.commentId,
- commentRunId: issueComments.createdByRunId,
- idempotencyKey: chatPublications.idempotencyKey,
- payload: chatPublications.payload,
- providerMessageId: chatPublications.providerMessageId,
- })
- .from(chatPublications)
- .leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.state, "published"),
- isNotNull(chatPublications.providerMessageId),
- ),
- )
- .orderBy(desc(chatPublications.createdAt), desc(chatPublications.id));
- const rowRunId = (row: (typeof rows)[number]) => {
- const milestoneMatch =
- /^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
- row.idempotencyKey,
- );
- return milestoneMatch?.[1] ?? row.commentRunId ?? null;
- };
- for (const candidate of rows) {
- if (
- !candidate.providerMessageId ||
- !["queued", "working"].includes(candidate.payload.progressState ?? "")
- ) {
- continue;
- }
- const runId = rowRunId(candidate);
- if (!runId) continue;
- const laneClosed = rows.some((row) => {
- if (rowRunId(row) !== runId) return false;
- return (
- ["waiting_for_input", "completed", "failed"].includes(
- row.payload.progressState ?? "",
- ) ||
- (row.commentId !== null && row.payload.progressState === undefined)
- );
- });
- if (!laneClosed) return candidate.providerMessageId;
- }
- return null;
- }
-
- async function closeProgressToReplace(
- publication: typeof chatPublications.$inferSelect,
- endpoint: EndpointRow,
- ): Promise<{ providerMessageId: string; runId: string } | null> {
- if (
- !["telegram", "slack", "discord", "microsoft-teams"].includes(
- endpoint.provider,
- ) ||
- !publication.idempotencyKey.startsWith("control:close:")
- )
- return null;
- const current = alias(chatPublications, "close_current_progress");
- const plainText = (table: typeof chatPublications | typeof current) =>
- and(
- sql`jsonb_typeof(${table.payload}->'text') = 'string'`,
- sql`${table.payload}->'card' is null`,
- sql`${table.payload}->'interactionId' is null`,
- sql`${table.payload}->'transportPart' is null`,
- sql`(${table.payload}->'attachmentIds' is null or ${table.payload}->'attachmentIds' = '[]'::jsonb)`,
- );
- const ownedMilestone = (table: typeof chatPublications | typeof current) =>
- or(
- sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':queued:' || ${endpoint.id}`,
- sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id}`,
- sql`${table.idempotencyKey} like 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id} || ':native:%'`,
- );
- // A historical progress row alone is not edit authority: its exact current
- // outbound link must still belong to that run's progress (or an interleaved
- // status control). Read under the same credential lease as final delivery.
- const lanes = await db
- .selectDistinct({
- providerMessageId: chatMessageLinks.providerMessageId,
- runId: heartbeatRuns.id,
- })
- .from(chatPublications)
- .innerJoin(
- heartbeatRuns,
- and(
- eq(heartbeatRuns.companyId, publication.companyId),
- eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
- sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
- sql`split_part(${chatPublications.idempotencyKey}, ':', 2) = ${heartbeatRuns.id}::text`,
- ),
- )
- .innerJoin(
- chatMessageLinks,
- and(
- eq(chatMessageLinks.companyId, publication.companyId),
- eq(chatMessageLinks.endpointId, publication.endpointId),
- eq(chatMessageLinks.conversationId, publication.conversationId),
- eq(chatMessageLinks.direction, "outbound"),
- eq(
- chatMessageLinks.providerMessageId,
- chatPublications.providerMessageId,
- ),
- ),
- )
- .innerJoin(
- current,
- and(
- eq(current.id, chatMessageLinks.publicationId),
- eq(current.companyId, publication.companyId),
- eq(current.endpointId, publication.endpointId),
- eq(current.conversationId, publication.conversationId),
- eq(current.issueId, publication.issueId),
- eq(current.state, "published"),
- eq(current.providerMessageId, chatMessageLinks.providerMessageId),
- isNull(current.commentId),
- plainText(current),
- or(
- and(
- sql`${current.payload}->>'progressState' in ('queued', 'working')`,
- ownedMilestone(current),
- ),
- like(current.idempotencyKey, "control:status:%"),
- ),
- ),
- )
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- eq(chatPublications.state, "published"),
- isNull(chatPublications.commentId),
- plainText(chatPublications),
- sql`${chatPublications.payload}->>'progressState' in ('queued', 'working')`,
- ownedMilestone(chatPublications),
- ),
- )
- .limit(2);
- // One close receipt owns one edit. Multiple independent lanes require a
- // separate durable multi-effect protocol, never an arbitrary latest guess.
- if (lanes.length !== 1) return null;
- const lane = lanes[0]!;
- const [possiblyConsumed] = await db
- .select({ id: chatPublications.id })
- .from(chatPublications)
- .leftJoin(
- issueComments,
- and(
- eq(issueComments.id, chatPublications.commentId),
- eq(issueComments.companyId, publication.companyId),
- eq(issueComments.issueId, publication.issueId),
- ),
- )
- .where(
- and(
- eq(chatPublications.companyId, publication.companyId),
- eq(chatPublications.endpointId, publication.endpointId),
- eq(chatPublications.conversationId, publication.conversationId),
- eq(chatPublications.issueId, publication.issueId),
- or(
- inArray(chatPublications.state, [
- "pending",
- "retry",
- "streaming",
- "delivery_unknown",
- "published",
- ]),
- // Stopping retries (including an operator's unknown-delivery
- // cancellation) does not prove an attempted edit never arrived.
- and(
- inArray(chatPublications.state, ["cancelled", "failed"]),
- gt(chatPublications.attempts, 0),
- ),
- ),
- or(
- eq(issueComments.createdByRunId, lane.runId),
- // Question/confirmation prompts consume this same run's progress
- // ID through interactionPromptPublicationToReplace, but have no
- // commentId. A stale working link is not proof that their edit
- // never reached the provider (including an unconfirmed receipt).
- sql`exists (
- select 1 from issue_thread_interactions consuming_interaction
- where consuming_interaction.company_id = ${publication.companyId}::uuid
- and consuming_interaction.issue_id = ${publication.issueId}::uuid
- and consuming_interaction.source_run_id = ${lane.runId}::uuid
- and consuming_interaction.kind in ('ask_user_questions', 'request_confirmation')
- and ${chatPublications.payload}->>'interactionId' = consuming_interaction.id::text
- and ${chatPublications.idempotencyKey} = 'interaction:' || consuming_interaction.id::text || ':' || ${publication.endpointId}
- )`,
- eq(
- chatPublications.idempotencyKey,
- `run:${lane.runId}:completed:${endpoint.id}`,
- ),
- eq(
- chatPublications.idempotencyKey,
- `run:${lane.runId}:failed:${endpoint.id}`,
- ),
- ),
- ),
- )
- .limit(1);
- // A final may have reached the provider before its database receipt. Even an
- // unchanged working link cannot disprove that ambiguous provider effect.
- return possiblyConsumed ? null : lane;
- }
-
async function currentTaskControlPayload(
publication: typeof chatPublications.$inferSelect,
): Promise {
diff --git a/server/src/services/chat-channels/imessage-photon/publications.ts b/server/src/services/chat-channels/imessage-photon/publications.ts
new file mode 100644
index 00000000000..d85b4410cb5
--- /dev/null
+++ b/server/src/services/chat-channels/imessage-photon/publications.ts
@@ -0,0 +1,9 @@
+import type { SafeChatPublicationPayload } from "@paperclipai/shared";
+
+export function imessageProgressSupersessionReason(
+ progress: SafeChatPublicationPayload["progressState"],
+): string | null {
+ return progress && ["queued", "working"].includes(progress)
+ ? "iMessage uses typing instead of progress bubbles"
+ : null;
+}
diff --git a/server/src/services/chat-channels/publication-replacement.ts b/server/src/services/chat-channels/publication-replacement.ts
new file mode 100644
index 00000000000..626161201a2
--- /dev/null
+++ b/server/src/services/chat-channels/publication-replacement.ts
@@ -0,0 +1,965 @@
+import { imessageProgressSupersessionReason } from "./imessage-photon/publications.js";
+import { fastResponseRequests } from "@paperclipai/db";
+
+import {
+ and,
+ desc,
+ eq,
+ gt,
+ inArray,
+ isNotNull,
+ isNull,
+ like,
+ ne,
+ or,
+ sql,
+} from "drizzle-orm";
+import { alias } from "drizzle-orm/pg-core";
+
+import type { Db } from "@paperclipai/db";
+
+import {
+ agentWakeupRequests,
+ chatEndpoints,
+ chatMessageLinks,
+ chatPublications,
+ heartbeatRuns,
+ issueComments,
+ issueThreadInteractions,
+ issues,
+} from "@paperclipai/db";
+import type {
+ SafeChatPublicationPayload,
+} from "@paperclipai/shared";
+import {
+ isUuidLike,
+} from "@paperclipai/shared";
+
+import {
+ NativeChatReviewPresentationContentionError,
+} from "../native-runtime/native-chat-review-presentation.js";
+import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
+
+import {
+ inboundWakePublicationKey,
+ parseInboundWakePublicationKey,
+} from "../chat-inbound-wakeup-publications.js";
+
+type EndpointRow = typeof chatEndpoints.$inferSelect;
+type DbOrTransaction = Db | Parameters[0]>[0];
+type ChatPublicationReplacementOptions = {
+ endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
+ authorizeRetainedChatSourcePublication: (database: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise;
+};
+
+export function createChatPublicationReplacement(db: Db, options: ChatPublicationReplacementOptions) {
+ const { endpointRecord, authorizeRetainedChatSourcePublication } = options;
+
+
+ function runIdFromMilestonePublication(
+ publication: typeof chatPublications.$inferSelect,
+ ): string | null {
+ if (!publication.payload.progressState) return null;
+ const match =
+ /^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
+ publication.idempotencyKey,
+ );
+ const runId = match?.[1] ?? null;
+ // This identifier is also compared against a UUID column below. Old or
+ // manually repaired rows must not be allowed to turn the global
+ // publication sweep into a PostgreSQL cast error.
+ return isUuidLike(runId) ? runId : null;
+ }
+
+ async function runOwnershipMilestoneSupersessionReason(
+ publication: typeof chatPublications.$inferSelect,
+ ): Promise {
+ const progress = publication.payload.progressState;
+ const typingReason = imessageProgressSupersessionReason(progress);
+ if (typingReason) {
+ const endpoint = await endpointRecord(publication.endpointId);
+ if (endpoint?.endpoint.provider === "imessage-photon") return typingReason;
+ }
+ if (
+ !progress ||
+ !["queued", "working", "waiting_for_input"].includes(progress)
+ ) {
+ return null;
+ }
+ const runId = runIdFromMilestonePublication(publication);
+ if (!runId) return null;
+ const run = await db.transaction(async (tx) => {
+ // Use native finalization's issue -> run lock order. If terminalization
+ // won, the current status suppresses this obsolete provider update. If
+ // this short authorization snapshot wins, the progress was still true
+ // at its durable send boundary and the later final remains authoritative.
+ const currentIssue = await tx
+ .select({ id: issues.id })
+ .from(issues)
+ .where(
+ and(
+ eq(issues.id, publication.issueId),
+ eq(issues.companyId, publication.companyId),
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (!currentIssue) return null;
+ return tx
+ .select({
+ status: heartbeatRuns.status,
+ errorCode: heartbeatRuns.errorCode,
+ })
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, runId),
+ eq(heartbeatRuns.companyId, publication.companyId),
+ sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${publication.issueId}`,
+ ),
+ )
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ });
+ const baseProgressKey = `run:${runId}:${progress}:${publication.endpointId}`;
+ const nativeProgressPrefix = `run:${runId}:working:${publication.endpointId}:native:`;
+ const isTerminalizableProgress =
+ (progress === "queued" || progress === "working") &&
+ (publication.idempotencyKey === baseProgressKey ||
+ publication.idempotencyKey.startsWith(nativeProgressPrefix));
+ if (
+ isTerminalizableProgress &&
+ run &&
+ ["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(
+ run.status,
+ )
+ ) {
+ return "Run reached a terminal state before progress delivery";
+ }
+ // Match only the closed ownership-attention projection. Historical progress
+ // without an extant run retains its existing delivery semantics.
+ const ownershipBlocked =
+ run?.status === "running" &&
+ [
+ "native_execution_ownership_unverified",
+ "native_adopted_runner_authentication_timeout",
+ ].includes(run.errorCode ?? "");
+ if (progress === "waiting_for_input") {
+ return ownershipBlocked
+ ? null
+ : "Run ownership attention no longer applies to the bound task";
+ }
+ return ownershipBlocked
+ ? "Run progress was superseded by ownership recovery attention"
+ : null;
+ }
+
+ async function providerProgressLaneConsumed(
+ publication: typeof chatPublications.$inferSelect,
+ providerMessageId: string,
+ ): Promise {
+ // Published progress rows retain their old provider ID after an edit.
+ // Consult that exact message's current outbound link, not the conversation
+ // tail: an authored answer or failure has consumed the lane, while an
+ // interleaved task-status update may still be replaced normally.
+ const [consumed] = await db
+ .select({ id: chatPublications.id })
+ .from(chatMessageLinks)
+ .innerJoin(
+ chatPublications,
+ and(
+ eq(chatPublications.id, chatMessageLinks.publicationId),
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(chatPublications.providerMessageId, providerMessageId),
+ eq(chatPublications.state, "published"),
+ ),
+ )
+ .where(
+ and(
+ eq(chatMessageLinks.companyId, publication.companyId),
+ eq(chatMessageLinks.endpointId, publication.endpointId),
+ eq(chatMessageLinks.conversationId, publication.conversationId),
+ eq(chatMessageLinks.direction, "outbound"),
+ eq(chatMessageLinks.providerMessageId, providerMessageId),
+ or(
+ and(
+ isNotNull(chatPublications.commentId),
+ sql`${chatPublications.payload}->>'progressState' is null`,
+ ),
+ sql`${chatPublications.payload}->>'progressState' = 'failed'`,
+ ),
+ ),
+ )
+ .limit(1);
+ return Boolean(consumed);
+ }
+
+ async function committedResponseMayReplaceFailure(
+ publication: typeof chatPublications.$inferSelect,
+ runId: string,
+ providerMessageId: string,
+ ): Promise {
+ if (
+ !publication.commentId ||
+ publication.payload.progressState !== undefined
+ )
+ return false;
+ return db
+ .transaction(async (tx) => {
+ const [run] = await tx
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, runId),
+ eq(heartbeatRuns.companyId, publication.companyId),
+ eq(heartbeatRuns.nativeIssueId, publication.issueId),
+ eq(heartbeatRuns.runtimeMode, "native"),
+ eq(heartbeatRuns.status, "succeeded"),
+ isNotNull(heartbeatRuns.finishedAt),
+ ),
+ )
+ .for("share", { noWait: true });
+ const presentation = run?.resultJson?.presentationDecision as
+ Record | undefined;
+ if (
+ !run?.resultJson?.nativeCommittedChatResponse ||
+ presentation?.commentId !== publication.commentId
+ )
+ return false;
+ const [comment] = await tx
+ .select({ id: issueComments.id })
+ .from(issueComments)
+ .where(
+ and(
+ eq(issueComments.id, publication.commentId!),
+ eq(issueComments.companyId, publication.companyId),
+ eq(issueComments.issueId, publication.issueId),
+ eq(issueComments.createdByRunId, runId),
+ eq(issueComments.authorAgentId, run.agentId),
+ isNull(issueComments.deletedAt),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (!comment) return false;
+ const [priorAnswer] = await tx
+ .select({ id: chatPublications.id })
+ .from(chatPublications)
+ .innerJoin(
+ issueComments,
+ and(
+ eq(issueComments.id, chatPublications.commentId),
+ eq(issueComments.companyId, publication.companyId),
+ eq(issueComments.createdByRunId, runId),
+ ),
+ )
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ ne(chatPublications.id, publication.id),
+ inArray(chatPublications.state, [
+ "published",
+ "streaming",
+ "delivery_unknown",
+ ]),
+ sql`${chatPublications.payload}->>'progressState' is null`,
+ ),
+ )
+ .limit(1);
+ if (priorAnswer) return false;
+ // A historical progress row can retain this ID after a later edit.
+ // Only its CURRENT outbound link may grant this narrow exception; an
+ // authored answer or another run's failure must remain consumed.
+ const [failure] = await tx
+ .select({ id: chatPublications.id })
+ .from(chatMessageLinks)
+ .innerJoin(
+ chatPublications,
+ and(
+ eq(chatPublications.id, chatMessageLinks.publicationId),
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(chatPublications.providerMessageId, providerMessageId),
+ eq(
+ chatPublications.idempotencyKey,
+ `run:${runId}:failed:${publication.endpointId}`,
+ ),
+ eq(chatPublications.state, "published"),
+ isNull(chatPublications.commentId),
+ sql`${chatPublications.payload}->>'progressState' = 'failed'`,
+ ),
+ )
+ .where(
+ and(
+ eq(chatMessageLinks.companyId, publication.companyId),
+ eq(chatMessageLinks.endpointId, publication.endpointId),
+ eq(chatMessageLinks.conversationId, publication.conversationId),
+ eq(chatMessageLinks.providerMessageId, providerMessageId),
+ eq(chatMessageLinks.direction, "outbound"),
+ ),
+ );
+ if (!failure) return false;
+ // The marker is read from the authoritative run, never the publication
+ // payload. Recheck accepted result/decision plus the complete current
+ // source batch, actor, access and epoch before borrowing its failure lane.
+ // The transport claim repeats those checks at the I/O boundary.
+ return authorizeRetainedChatSourcePublication(tx, publication);
+ })
+ .catch((error: unknown) => {
+ if (isExternalChatWaitAuthorizationContention(error))
+ throw new NativeChatReviewPresentationContentionError();
+ throw error;
+ });
+ }
+
+ async function runPublicationToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (payload.attachmentIds?.length) return null;
+ const currentRunId =
+ runIdFromMilestonePublication(publication) ??
+ (publication.commentId
+ ? await db
+ .select({ runId: issueComments.createdByRunId })
+ .from(issueComments)
+ .where(eq(issueComments.id, publication.commentId))
+ .then((rows) => rows[0]?.runId ?? null)
+ : null);
+ if (!currentRunId) return null;
+ return db
+ .select({
+ id: chatPublications.id,
+ commentId: chatPublications.commentId,
+ providerMessageId: chatPublications.providerMessageId,
+ payload: chatPublications.payload,
+ })
+ .from(chatPublications)
+ .leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.state, "published"),
+ isNotNull(chatPublications.providerMessageId),
+ or(
+ like(chatPublications.idempotencyKey, `run:${currentRunId}:%`),
+ eq(issueComments.createdByRunId, currentRunId),
+ ),
+ ),
+ )
+ .orderBy(desc(chatPublications.createdAt), desc(chatPublications.id))
+ .then(async (rows) => {
+ // Progress updates are one replaceable provider-message lane per run.
+ // The first durable agent comment may turn that placeholder into the
+ // terminal response, but later comments from the same run are distinct
+ // user-visible outputs and must be posted separately. Re-editing the
+ // placeholder for each comment silently erases the earlier replies.
+ if (
+ publication.commentId &&
+ rows.some(
+ (row) =>
+ row.commentId !== null && row.payload.progressState === undefined,
+ )
+ )
+ return null;
+ const replacement = rows.find(
+ (row) =>
+ Boolean(row.providerMessageId) &&
+ row.payload.progressState !== undefined,
+ );
+ if (!replacement?.providerMessageId) return null;
+ if (
+ (await providerProgressLaneConsumed(
+ publication,
+ replacement.providerMessageId,
+ )) &&
+ !(await committedResponseMayReplaceFailure(
+ publication,
+ currentRunId,
+ replacement.providerMessageId,
+ ))
+ )
+ return null;
+ // Replacement identity belongs to the run, not to the provider-visible
+ // tail. A status/control reply may legitimately interleave while the run
+ // is active; making the tail the edit candidate would strand this run's
+ // working placeholder forever. The query is bounded by endpoint,
+ // conversation (the task generation), and run id, so an interleaved
+ // control or another run can never donate its provider message here.
+ return replacement.providerMessageId;
+ });
+ }
+
+ async function receiptReactionCompletionRunId(
+ tx: Db,
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (
+ payload.progressState === "completed" ||
+ payload.progressState === "failed"
+ ) {
+ return runIdFromMilestonePublication(publication);
+ }
+ if (publication.commentId) {
+ const runId = await tx
+ .select({ runId: issueComments.createdByRunId })
+ .from(issueComments)
+ .where(
+ and(
+ eq(issueComments.id, publication.commentId),
+ eq(issueComments.companyId, publication.companyId),
+ ),
+ )
+ .then((rows) => rows[0]?.runId ?? null);
+ return isUuidLike(runId) ? runId : null;
+ }
+ if (
+ payload.interactionId &&
+ publication.idempotencyKey ===
+ `interaction:${payload.interactionId}:${publication.endpointId}`
+ ) {
+ const runId = await tx
+ .select({ runId: issueThreadInteractions.sourceRunId })
+ .from(issueThreadInteractions)
+ .where(
+ and(
+ eq(issueThreadInteractions.id, payload.interactionId),
+ eq(issueThreadInteractions.companyId, publication.companyId),
+ eq(issueThreadInteractions.issueId, publication.issueId),
+ ),
+ )
+ .then((rows) => rows[0]?.runId ?? null);
+ return isUuidLike(runId) ? runId : null;
+ }
+ return null;
+ }
+
+ async function inboundWakePublicationToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (payload.attachmentIds?.length) return null;
+ const notice = parseInboundWakePublicationKey(publication.idempotencyKey);
+ let wakeId: string | null =
+ notice && notice.state !== "queued" ? notice.wakeId : null;
+ let runId = runIdFromMilestonePublication(publication);
+ if (
+ !notice &&
+ !runId &&
+ payload.interactionId &&
+ publication.idempotencyKey ===
+ `interaction:${payload.interactionId}:${publication.endpointId}`
+ ) {
+ const [interaction] = await db
+ .select({ runId: issueThreadInteractions.sourceRunId })
+ .from(issueThreadInteractions)
+ .where(
+ and(
+ eq(issueThreadInteractions.id, payload.interactionId),
+ eq(issueThreadInteractions.companyId, publication.companyId),
+ eq(issueThreadInteractions.issueId, publication.issueId),
+ inArray(issueThreadInteractions.kind, [
+ "ask_user_questions",
+ "request_confirmation",
+ ]),
+ ),
+ );
+ runId = interaction?.runId ?? null;
+ }
+ if (!notice && !runId && publication.commentId) {
+ const [comment] = await db
+ .select({ runId: issueComments.createdByRunId })
+ .from(issueComments)
+ .where(
+ and(
+ eq(issueComments.id, publication.commentId),
+ eq(issueComments.companyId, publication.companyId),
+ eq(issueComments.issueId, publication.issueId),
+ ),
+ );
+ runId = comment?.runId ?? null;
+ }
+ let runContext: Record | null = null;
+ if (runId) {
+ if (publication.commentId) {
+ const [priorAnswer] = await db
+ .select({ id: chatPublications.id })
+ .from(chatPublications)
+ .innerJoin(
+ issueComments,
+ eq(issueComments.id, chatPublications.commentId),
+ )
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.state, "published"),
+ eq(issueComments.createdByRunId, runId),
+ sql`${chatPublications.payload}->>'progressState' is null`,
+ ),
+ )
+ .limit(1);
+ if (priorAnswer) return null;
+ }
+ const [run] = await db
+ .select({
+ wakeId: heartbeatRuns.wakeupRequestId,
+ context: heartbeatRuns.contextSnapshot,
+ })
+ .from(heartbeatRuns)
+ .innerJoin(
+ agentWakeupRequests,
+ and(
+ eq(agentWakeupRequests.id, heartbeatRuns.wakeupRequestId),
+ eq(agentWakeupRequests.runId, heartbeatRuns.id),
+ eq(agentWakeupRequests.companyId, heartbeatRuns.companyId),
+ eq(agentWakeupRequests.agentId, heartbeatRuns.agentId),
+ // Failed/cancelled is the admitted run's outcome, not loss of
+ // its original admission. Its terminal milestone still owns
+ // this exact lane even if no working update was sent first.
+ ne(agentWakeupRequests.status, "skipped"),
+ ),
+ )
+ .where(
+ and(
+ eq(heartbeatRuns.id, runId),
+ eq(heartbeatRuns.companyId, publication.companyId),
+ sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
+ ),
+ );
+ wakeId = run?.wakeId ?? null;
+ runContext = run?.context ?? null;
+ }
+ if (!wakeId) return null;
+ const [queued] = await db
+ .select({
+ commentId: chatPublications.commentId,
+ providerMessageId: chatPublications.providerMessageId,
+ })
+ .from(chatPublications)
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(chatPublications.state, "published"),
+ isNotNull(chatPublications.providerMessageId),
+ eq(
+ chatPublications.idempotencyKey,
+ inboundWakePublicationKey(
+ wakeId,
+ "queued",
+ publication.endpointId,
+ publication.conversationId,
+ ),
+ ),
+ ),
+ );
+ if (!queued?.commentId || !queued.providerMessageId) return null;
+ // The run helper may have rejected this same provider ID because its
+ // queued/working placeholder is already an answer or terminal failure.
+ // Falling back to the older wake row must not erase that newer output.
+ if (
+ await providerProgressLaneConsumed(publication, queued.providerMessageId)
+ )
+ return null;
+ if (notice && notice.state !== "queued")
+ return queued.commentId === publication.commentId
+ ? queued.providerMessageId
+ : null;
+ // A deferred owner can be a Board wake into which the exact external
+ // comment coalesced. The run must actually contain that source comment;
+ // wake ID alone cannot authorize a different successor's edit lane.
+ return runContext &&
+ (runContext.wakeCommentId === queued.commentId ||
+ runContext.commentId === queued.commentId ||
+ (Array.isArray(runContext.wakeCommentIds) &&
+ runContext.wakeCommentIds.includes(queued.commentId)))
+ ? queued.providerMessageId
+ : null;
+ }
+
+ async function interactionResolutionPublicationToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (
+ !publication.idempotencyKey.startsWith("interaction-resolution:") ||
+ !payload.interactionId
+ )
+ return null;
+ return db
+ .select({ providerMessageId: chatPublications.providerMessageId })
+ .from(chatPublications)
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(
+ chatPublications.idempotencyKey,
+ `interaction:${payload.interactionId}:${publication.endpointId}`,
+ ),
+ eq(chatPublications.state, "published"),
+ isNotNull(chatPublications.providerMessageId),
+ ),
+ )
+ .then((rows) => rows[0]?.providerMessageId ?? null);
+ }
+
+ async function interactionPromptPublicationToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (
+ !payload.interactionId ||
+ publication.idempotencyKey !==
+ `interaction:${payload.interactionId}:${publication.endpointId}`
+ ) {
+ return null;
+ }
+ const interaction = await db
+ .select({
+ kind: issueThreadInteractions.kind,
+ sourceRunId: issueThreadInteractions.sourceRunId,
+ })
+ .from(issueThreadInteractions)
+ .where(
+ and(
+ eq(issueThreadInteractions.id, payload.interactionId),
+ eq(issueThreadInteractions.companyId, publication.companyId),
+ eq(issueThreadInteractions.issueId, publication.issueId),
+ inArray(issueThreadInteractions.kind, [
+ "ask_user_questions",
+ "request_confirmation",
+ ]),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ if (!interaction?.sourceRunId) return null;
+
+ // A provider-visible interaction is this run's response, not an additional
+ // message beside its progress indicator. Move the durable interaction
+ // publication onto the exact source run's provider-message lane. This
+ // retires both the normal working placeholder and the queued placeholder
+ // when a very fast run asks before the working update is published.
+ for (const progressState of ["working", "queued"] as const) {
+ const providerMessageId = await db
+ .select({ providerMessageId: chatPublications.providerMessageId })
+ .from(chatPublications)
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(
+ chatPublications.idempotencyKey,
+ `run:${interaction.sourceRunId}:${progressState}:${publication.endpointId}`,
+ ),
+ eq(chatPublications.state, "published"),
+ isNotNull(chatPublications.providerMessageId),
+ ),
+ )
+ .then((rows) => rows[0]?.providerMessageId ?? null);
+ if (providerMessageId) return providerMessageId;
+ }
+ return null;
+ }
+
+ async function progressSupersededByFastResponse(publication: typeof chatPublications.$inferSelect): Promise {
+ if (!["queued", "working"].includes(publication.payload.progressState ?? "")) return false;
+ const runId = runIdFromMilestonePublication(publication);
+ const [run] = runId ? await db.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, publication.companyId), eq(heartbeatRuns.id, runId))) : [];
+ const sourceCommentId = typeof run?.context?.wakeCommentId === "string" ? run.context.wakeCommentId : typeof run?.context?.commentId === "string" ? run.context.commentId : publication.commentId;
+ if (!sourceCommentId) return false;
+ const [receipt] = await db.select({ id: fastResponseRequests.id }).from(fastResponseRequests).innerJoin(chatPublications,
+ and(eq(chatPublications.companyId, publication.companyId), eq(chatPublications.commentId, fastResponseRequests.commentId), eq(chatPublications.state, "published")))
+ .where(and(eq(fastResponseRequests.companyId, publication.companyId), eq(fastResponseRequests.sourceCommentId, sourceCommentId), eq(fastResponseRequests.endpointId, publication.endpointId), eq(fastResponseRequests.conversationId, publication.conversationId))).limit(1);
+ return Boolean(receipt);
+ }
+
+ async function runProgressSupersededByPublishedInteraction(
+ publication: typeof chatPublications.$inferSelect,
+ ): Promise {
+ if (
+ !["queued", "working"].includes(publication.payload.progressState ?? "")
+ ) {
+ return false;
+ }
+ const sourceRunId = runIdFromMilestonePublication(publication);
+ if (!sourceRunId) return false;
+ const interactionIds = await db
+ .select({ id: issueThreadInteractions.id })
+ .from(issueThreadInteractions)
+ .where(
+ and(
+ eq(issueThreadInteractions.companyId, publication.companyId),
+ eq(issueThreadInteractions.issueId, publication.issueId),
+ eq(issueThreadInteractions.sourceRunId, sourceRunId),
+ inArray(issueThreadInteractions.kind, [
+ "ask_user_questions",
+ "request_confirmation",
+ ]),
+ ),
+ )
+ .then((rows) => rows.map((row) => row.id));
+ if (interactionIds.length === 0) return false;
+ return db
+ .select({ id: chatPublications.id })
+ .from(chatPublications)
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(chatPublications.state, "published"),
+ inArray(
+ chatPublications.idempotencyKey,
+ interactionIds.map(
+ (interactionId) =>
+ `interaction:${interactionId}:${publication.endpointId}`,
+ ),
+ ),
+ ),
+ )
+ .limit(1)
+ .then((rows) => rows.length > 0);
+ }
+
+ async function taskStatusPublicationToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ payload: SafeChatPublicationPayload,
+ ): Promise {
+ if (
+ !publication.idempotencyKey.startsWith("control:status:") ||
+ payload.attachmentIds?.length
+ )
+ return null;
+ const rows = await db
+ .select({
+ commentId: chatPublications.commentId,
+ commentRunId: issueComments.createdByRunId,
+ idempotencyKey: chatPublications.idempotencyKey,
+ payload: chatPublications.payload,
+ providerMessageId: chatPublications.providerMessageId,
+ })
+ .from(chatPublications)
+ .leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.state, "published"),
+ isNotNull(chatPublications.providerMessageId),
+ ),
+ )
+ .orderBy(desc(chatPublications.createdAt), desc(chatPublications.id));
+ const rowRunId = (row: (typeof rows)[number]) => {
+ const milestoneMatch =
+ /^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
+ row.idempotencyKey,
+ );
+ return milestoneMatch?.[1] ?? row.commentRunId ?? null;
+ };
+ for (const candidate of rows) {
+ if (
+ !candidate.providerMessageId ||
+ !["queued", "working"].includes(candidate.payload.progressState ?? "")
+ ) {
+ continue;
+ }
+ const runId = rowRunId(candidate);
+ if (!runId) continue;
+ const laneClosed = rows.some((row) => {
+ if (rowRunId(row) !== runId) return false;
+ return (
+ ["waiting_for_input", "completed", "failed"].includes(
+ row.payload.progressState ?? "",
+ ) ||
+ (row.commentId !== null && row.payload.progressState === undefined)
+ );
+ });
+ if (!laneClosed) return candidate.providerMessageId;
+ }
+ return null;
+ }
+
+ async function closeProgressToReplace(
+ publication: typeof chatPublications.$inferSelect,
+ endpoint: EndpointRow,
+ ): Promise<{ providerMessageId: string; runId: string } | null> {
+ if (
+ !["telegram", "slack", "discord", "microsoft-teams"].includes(
+ endpoint.provider,
+ ) ||
+ !publication.idempotencyKey.startsWith("control:close:")
+ )
+ return null;
+ const current = alias(chatPublications, "close_current_progress");
+ const plainText = (table: typeof chatPublications | typeof current) =>
+ and(
+ sql`jsonb_typeof(${table.payload}->'text') = 'string'`,
+ sql`${table.payload}->'card' is null`,
+ sql`${table.payload}->'interactionId' is null`,
+ sql`${table.payload}->'transportPart' is null`,
+ sql`(${table.payload}->'attachmentIds' is null or ${table.payload}->'attachmentIds' = '[]'::jsonb)`,
+ );
+ const ownedMilestone = (table: typeof chatPublications | typeof current) =>
+ or(
+ sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':queued:' || ${endpoint.id}`,
+ sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id}`,
+ sql`${table.idempotencyKey} like 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id} || ':native:%'`,
+ );
+ // A historical progress row alone is not edit authority: its exact current
+ // outbound link must still belong to that run's progress (or an interleaved
+ // status control). Read under the same credential lease as final delivery.
+ const lanes = await db
+ .selectDistinct({
+ providerMessageId: chatMessageLinks.providerMessageId,
+ runId: heartbeatRuns.id,
+ })
+ .from(chatPublications)
+ .innerJoin(
+ heartbeatRuns,
+ and(
+ eq(heartbeatRuns.companyId, publication.companyId),
+ eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
+ sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
+ sql`split_part(${chatPublications.idempotencyKey}, ':', 2) = ${heartbeatRuns.id}::text`,
+ ),
+ )
+ .innerJoin(
+ chatMessageLinks,
+ and(
+ eq(chatMessageLinks.companyId, publication.companyId),
+ eq(chatMessageLinks.endpointId, publication.endpointId),
+ eq(chatMessageLinks.conversationId, publication.conversationId),
+ eq(chatMessageLinks.direction, "outbound"),
+ eq(
+ chatMessageLinks.providerMessageId,
+ chatPublications.providerMessageId,
+ ),
+ ),
+ )
+ .innerJoin(
+ current,
+ and(
+ eq(current.id, chatMessageLinks.publicationId),
+ eq(current.companyId, publication.companyId),
+ eq(current.endpointId, publication.endpointId),
+ eq(current.conversationId, publication.conversationId),
+ eq(current.issueId, publication.issueId),
+ eq(current.state, "published"),
+ eq(current.providerMessageId, chatMessageLinks.providerMessageId),
+ isNull(current.commentId),
+ plainText(current),
+ or(
+ and(
+ sql`${current.payload}->>'progressState' in ('queued', 'working')`,
+ ownedMilestone(current),
+ ),
+ like(current.idempotencyKey, "control:status:%"),
+ ),
+ ),
+ )
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ eq(chatPublications.state, "published"),
+ isNull(chatPublications.commentId),
+ plainText(chatPublications),
+ sql`${chatPublications.payload}->>'progressState' in ('queued', 'working')`,
+ ownedMilestone(chatPublications),
+ ),
+ )
+ .limit(2);
+ // One close receipt owns one edit. Multiple independent lanes require a
+ // separate durable multi-effect protocol, never an arbitrary latest guess.
+ if (lanes.length !== 1) return null;
+ const lane = lanes[0]!;
+ const [possiblyConsumed] = await db
+ .select({ id: chatPublications.id })
+ .from(chatPublications)
+ .leftJoin(
+ issueComments,
+ and(
+ eq(issueComments.id, chatPublications.commentId),
+ eq(issueComments.companyId, publication.companyId),
+ eq(issueComments.issueId, publication.issueId),
+ ),
+ )
+ .where(
+ and(
+ eq(chatPublications.companyId, publication.companyId),
+ eq(chatPublications.endpointId, publication.endpointId),
+ eq(chatPublications.conversationId, publication.conversationId),
+ eq(chatPublications.issueId, publication.issueId),
+ or(
+ inArray(chatPublications.state, [
+ "pending",
+ "retry",
+ "streaming",
+ "delivery_unknown",
+ "published",
+ ]),
+ // Stopping retries (including an operator's unknown-delivery
+ // cancellation) does not prove an attempted edit never arrived.
+ and(
+ inArray(chatPublications.state, ["cancelled", "failed"]),
+ gt(chatPublications.attempts, 0),
+ ),
+ ),
+ or(
+ eq(issueComments.createdByRunId, lane.runId),
+ // Question/confirmation prompts consume this same run's progress
+ // ID through interactionPromptPublicationToReplace, but have no
+ // commentId. A stale working link is not proof that their edit
+ // never reached the provider (including an unconfirmed receipt).
+ sql`exists (
+ select 1 from issue_thread_interactions consuming_interaction
+ where consuming_interaction.company_id = ${publication.companyId}::uuid
+ and consuming_interaction.issue_id = ${publication.issueId}::uuid
+ and consuming_interaction.source_run_id = ${lane.runId}::uuid
+ and consuming_interaction.kind in ('ask_user_questions', 'request_confirmation')
+ and ${chatPublications.payload}->>'interactionId' = consuming_interaction.id::text
+ and ${chatPublications.idempotencyKey} = 'interaction:' || consuming_interaction.id::text || ':' || ${publication.endpointId}
+ )`,
+ eq(
+ chatPublications.idempotencyKey,
+ `run:${lane.runId}:completed:${endpoint.id}`,
+ ),
+ eq(
+ chatPublications.idempotencyKey,
+ `run:${lane.runId}:failed:${endpoint.id}`,
+ ),
+ ),
+ ),
+ )
+ .limit(1);
+ // A final may have reached the provider before its database receipt. Even an
+ // unchanged working link cannot disprove that ambiguous provider effect.
+ return possiblyConsumed ? null : lane;
+ }
+
+ return { runIdFromMilestonePublication, runOwnershipMilestoneSupersessionReason, runPublicationToReplace, receiptReactionCompletionRunId, inboundWakePublicationToReplace, interactionResolutionPublicationToReplace, interactionPromptPublicationToReplace, progressSupersededByFastResponse, runProgressSupersededByPublishedInteraction, taskStatusPublicationToReplace, closeProgressToReplace };
+}
From d497d1ccecb8756c2219a72b279cfd1465156615 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:49:34 -0500
Subject: [PATCH 09/23] refactor(chat): extract failed-run retry coordination
(#15845)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip manages agent work and sends results back to chat.
> - A failed chat run can be retried only while its original source
remains authorized.
> - That decision includes persisted source evidence and native runtime
state.
> - Retry claims also share in-flight promises and depend on service
shutdown.
> - This PR groups retry authorization, execution and notices in one
module.
> - The service retains the live admission, runtime and shutdown
boundaries.
## Linked Issues or Issue Description
Refs #15844, #15836, #15834 and #15823. Stack slice 5 of 5, based on
`codex/chat-publication-replacement`.
Merge each predecessor first, then retarget this PR. Do not merge the
stack out of order.
Stack order: #15823 (provider messages) → #15834 (resource
reconciliation) → #15836 (identity links) → #15844 (publication
replacement) → #15845 (failed-run retries). GitHub native stack merging
preserves this order and automatically restacks the remaining branches.
## What Changed
- Extract failed-run retry admission and coordination while keeping the
in-flight map, cursor, error class and authority registrations scoped to
one chat service.
- Move the exact Teams uncertain-file continuation proof into
`microsoft-teams/file-transfer-source.ts`.
- Pass the existing transaction, explicit source identifiers and denial
factory. Return only the proven publication ID.
- Keep committed-result, complete source-batch, ancestor and
other-uncertain-effect checks in shared `failed-run-retry.ts`.
- Keep native execution recovery together; its provider references
describe runner backends, not chat channels.
- The retry coordinator is 1,825 lines and the Teams proof is 149 lines.
Restack on revised #15844 and record the reassessment.
## Verification
- Baseline: 59 retry cases and six negative Teams authority cases
passed. The combined 65 cases passed after the move.
- Cases cover retry ownership, service restart/lifetime, ambiguous Teams
receipts, sibling unknown effects, changed source authority and active
attempts.
- Teams guard, joins, locks, attempt checks and selected ID match after
explicit input substitutions. The shared coordinator retains its state
and cleanup ownership.
- Server typecheck, module-boundary and whitespace checks passed.
- Integration uses disposable databases and fixture provider transports;
no live third-party execution is claimed.
- Fresh Greptile review is 5/5 on
`4f7151409717458717633aa270441a8faf33664e`, with a successful check and
no open review threads.
- Final-stack `pnpm -r typecheck` and `pnpm build` passed at
`57ec5ba94aecd860471f9b14db46f99120c7c790`.
- All CI checks passed on `57ec5ba94aecd860471f9b14db46f99120c7c790`.
- Full local `pnpm test:run` stopped in the general-server group: 18,763
passed, 105 skipped, and one test timed out after 20 seconds in
unchanged `workspace-runtime.test.ts` ("does not accept an occupied
allocated port when listener ownership is unavailable"). The same case
passed in an isolated rerun without code, assertion or timeout changes
(1 passed, 175 excluded). The original timeout is retained as a failed
full run; later workspace and serialized groups were not reached. The
full local gate remains unverified.
- Merge verification on the restacked tree: 118 focused identity,
publication, Photon and retry integration tests passed; server typecheck
and module-boundary checks passed. GitHub’s rebased trees exactly match
the previously reviewed file contents.
- All current-head required checks passed on
`4f7151409717458717633aa270441a8faf33664e`: full CI; Greptile 5/5 with
no unresolved threads.
## Risks
- Authorization must remain fail-closed across committed output and
retry admission. The helper proves only the Teams transfer; the caller
still checks the complete source and result in the same transaction.
- In-flight promises, error-class identity, registration cleanup, cursor
state and the live shutdown getter must remain per-service. Their code
is unchanged by this channel-folder move.
- This PR depends on #15844. Merge and retarget in stack order.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning and repository tools. The
exact runtime model ID and context-window size are not exposed in this
session. No separate agent was used.
## 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
- [ ] 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
---
.../2026-10-10-chat-channels-refactor.md | 71 +
.../chat-failed-run-retry-boundary.test.ts | 83 +
server/src/services/chat-channels.ts | 1837 +----------------
.../chat-channels/failed-run-retry.ts | 1825 ++++++++++++++++
.../microsoft-teams/file-transfer-source.ts | 149 ++
5 files changed, 2153 insertions(+), 1812 deletions(-)
create mode 100644 server/src/__tests__/chat-failed-run-retry-boundary.test.ts
create mode 100644 server/src/services/chat-channels/failed-run-retry.ts
create mode 100644 server/src/services/chat-channels/microsoft-teams/file-transfer-source.ts
diff --git a/doc/plans/2026-10-10-chat-channels-refactor.md b/doc/plans/2026-10-10-chat-channels-refactor.md
index bba089b0c2a..21d90f2740a 100644
--- a/doc/plans/2026-10-10-chat-channels-refactor.md
+++ b/doc/plans/2026-10-10-chat-channels-refactor.md
@@ -233,3 +233,74 @@ Move the iMessage queued/working suppression policy and explanation to
all ownership queries unchanged. Baseline: 49 public-service publication and
Photon cases passed, including close-owned progress, interleaved Slack control,
Telegram confirmation and FIFO replacement. The same 49 cases passed after. Server typecheck and module boundaries pass.
+
+
+### Slice 5: failed chat run retry coordination
+
+Extract source validation, native failure evidence, retry preparation, claims,
+wake authorization and retry notices into `failed-run-retry.ts`. Keep the
+in-flight promise map, notice cursor, error class and two authority registrations
+inside the factory so each service retains its own lifetime. Keep the live
+shutdown and heartbeat callbacks. The service still owns cleanup order and waits
+for the module's pending retries before unregistering authorities.
+
+- Source: 36,393 lines / 1,400,636 bytes → 34,606 lines / 1,336,443 bytes.
+ Net reduction: 1,787 lines / 64,193 bytes.
+- Destination: 1,936 lines / 68,329 bytes.
+- Baseline: all 52 checks and 5/5 review passed on slice 4 (`166ea04ac6`, #15844).
+- Added three tests through the existing public service before the move. All
+ three passed before and after: duplicate calls share a promise, shutdown
+ during a pending read prevents a new claim and waits for completion, rejected
+ work permits a later attempt, and old-service cleanup preserves replacement
+ authority. These tests use controlled reads and do not require PostgreSQL.
+- All 21 moved declarations and every remaining statement match the predecessor
+ after restoring the documented live callbacks and pending-task accessor.
+ Server typecheck, module boundaries and whitespace checks pass.
+- Existing CI database fixtures cover corrected/edited sources, lifecycle epochs,
+ native recovery evidence, recreated services and concurrent retries. Local
+ full database execution remains blocked by the host's shared-memory limit.
+ Current-head full CI, broad local gates and review results belong in the PR.
+
+## Reassessment after five slices
+
+The service falls from 39,155 lines / 1,509,090 bytes at the rebased baseline to
+34,606 lines / 1,336,443 bytes: 4,549 lines and 172,647 bytes removed (11.6% of
+source lines). The five modules total 4,857 lines / 185,635 bytes, including their
+imports and typed boundaries. This is useful separation, not completion of the
+larger decomposition.
+
+The service function still spans 32,254 lines. Its 244 direct nested functions
+occupy 31,525 lines; 48 other statements occupy 435 lines of state, wiring,
+registration and public API. The remaining lines are whitespace, comments and
+syntax. The largest functions are `processMessage` (2,166), `handleAction`
+(1,074), `configureWithCredentialLease` (868), `processSelectedPublication` (729),
+`handleModalSubmit` (708), `handleWebhook` (685), `processLifecycleDelivery` (657)
+and `applyProviderLifecycleEffect` (593).
+
+The best next planning candidate is inbound message admission and persistence,
+starting from `processMessage` and its conversation/drain ownership. Define the
+transaction, live-message retention and scheduling contracts before moving it;
+do not move that entire function and claim its complexity is resolved. Action
+resolution and endpoint configuration are later candidates. Keep runtime lease
+acquisition, partial startup and shutdown together until their ownership contract
+is explicit. This batch ends with five stacked PRs; merging is separate work.
+
+### 2026-10-12 channel-folder reassessment: slice 5
+
+Keep retry admission, complete source-batch authorization, ancestor checks,
+in-flight deduplication, authority registration and shutdown coordination shared.
+References to native runner providers describe execution recovery, not chat
+channel implementations, and remain with that shared proof.
+
+Move the exact Teams file-transfer continuation proof into
+`microsoft-teams/file-transfer-source.ts`. It receives the caller's transaction,
+explicit source identifiers and original denial factory; it returns only the
+verified publication ID. The shared caller still validates the committed result,
+all original inputs and other uncertain publications. All provider proof guards,
+join predicates, row locks and attempt checks retain their statements.
+
+Baseline: 59 retry cases plus six negative Teams authority cases passed. These
+include service lifetime checks and transfer ambiguity without duplicate uploads.
+The same 65 cases passed after. Server typecheck and module boundaries pass.
+The remaining retry coordinator
+has 1,825 lines; the Teams proof module has 149 lines.
diff --git a/server/src/__tests__/chat-failed-run-retry-boundary.test.ts b/server/src/__tests__/chat-failed-run-retry-boundary.test.ts
new file mode 100644
index 00000000000..ad62169924d
--- /dev/null
+++ b/server/src/__tests__/chat-failed-run-retry-boundary.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { chatActions, type Db } from "@paperclipai/db";
+import { chatChannelService, type ChatChannelService, type ChatChannelServiceOptions } from "../services/chat-channels.js";
+import { authorizeCommittedChatResponse, CommittedChatResponseAuthorizationError } from "../services/durable-chat-wakeup.js";
+
+const services = new Set();
+afterEach(async () => {
+ for (const service of services) await service.shutdown();
+ services.clear();
+});
+
+function createService(db: Db) {
+ const wakeup = vi.fn().mockResolvedValue(undefined);
+ const runtimeShutdown = vi.fn().mockResolvedValue(undefined);
+ const service = chatChannelService(db, {
+ heartbeat: { wakeup },
+ runtime: { shutdown: runtimeShutdown } as unknown as ChatChannelServiceOptions["runtime"],
+ });
+ services.add(service);
+ return { service, wakeup, runtimeShutdown };
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => { resolve = done; });
+ return { promise, resolve };
+}
+
+describe("failed chat retry service lifetime", () => {
+ it("shares an in-flight retry and observes shutdown after its candidate read", async () => {
+ const candidate = deferred();
+ const reads = vi.fn((table: unknown) => table === chatActions ? candidate.promise : Promise.resolve([]));
+ const update = vi.fn(() => { throw new Error("A shutdown retry must not claim work"); });
+ const db = {
+ select: () => ({ from: (table: unknown) => ({ where: () => reads(table) }) }),
+ update,
+ } as unknown as Db;
+ const { service, wakeup, runtimeShutdown } = createService(db);
+ const first = service.processFailedChatRunRetry("retry-action");
+ const second = service.processFailedChatRunRetry("retry-action");
+ expect(second).toBe(first);
+ expect(reads).toHaveBeenCalledTimes(1);
+ const shutdown = service.shutdown();
+ expect(runtimeShutdown).not.toHaveBeenCalled();
+ candidate.resolve([{
+ id: "retry-action", companyId: "company", status: "issued",
+ payload: { issueId: "issue" }, result: {}, updatedAt: new Date(0),
+ }]);
+ await expect(first).resolves.toMatchObject({ actionId: "retry-action", status: "queued" });
+ await shutdown;
+ services.delete(service);
+ expect(update).not.toHaveBeenCalled();
+ expect(wakeup).not.toHaveBeenCalled();
+ expect(runtimeShutdown).toHaveBeenCalledTimes(1);
+ });
+
+ it("releases a rejected in-flight retry so a later attempt can read again", async () => {
+ const where = vi.fn().mockResolvedValue([]);
+ const db = { select: () => ({ from: () => ({ where }) }) } as unknown as Db;
+ const { service } = createService(db);
+ const first = service.processFailedChatRunRetry("missing-action");
+ await expect(first).rejects.toMatchObject({ status: 404 });
+ const second = service.processFailedChatRunRetry("missing-action");
+ expect(second).not.toBe(first);
+ await expect(second).rejects.toMatchObject({ status: 404 });
+ expect(where).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not unregister a replacement service's committed-response authority", async () => {
+ const db = {} as Db;
+ const first = createService(db).service;
+ const replacement = createService(db).service;
+ await first.shutdown();
+ services.delete(first);
+ const readFailure = new Error("replacement authority reached current source reads");
+ const tx = { select: () => { throw readFailure; } } as unknown as Db;
+ const input = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run", resultId: "result" };
+ await expect(authorizeCommittedChatResponse(db, tx, input)).rejects.toBe(readFailure);
+ await replacement.shutdown();
+ services.delete(replacement);
+ await expect(authorizeCommittedChatResponse(db, tx, input)).rejects.toBeInstanceOf(CommittedChatResponseAuthorizationError);
+ });
+});
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index 2e88f05b99c..fa5c85cea27 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -1,3 +1,4 @@
+import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "./chat-channels/failed-run-retry.js";
import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js";
import { createChatIdentityLinks } from "./chat-channels/identity-links.js";
import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js";
@@ -155,9 +156,6 @@ import type { Db } from "@paperclipai/db";
import {
createDurableChatWakeupRequest,
assertDurableChatWakeupReceipt,
- registerFailedChatRunRetryAuthority,
- registerCommittedChatResponseAuthority,
- CommittedChatResponseAuthorizationError,
} from "./durable-chat-wakeup.js";
import {
agentWakeupRequests,
@@ -177,15 +175,11 @@ import {
chatTeamsFileTransfers,
companyMemberships,
companySecretBindings,
- heartbeatRunEvents,
heartbeatRuns,
nativeRunFinalizations,
- nativeRunResults,
- environmentLeases,
issueComments,
issueAttachments,
issueThreadInteractions,
- issueQuestionResponseDeliveries,
issues,
toolApplications,
toolConnections,
@@ -208,7 +202,6 @@ import type {
} from "@paperclipai/shared";
import {
isAgentStatusInvokable,
- CHAT_PROVIDERS,
isUuidLike,
LOW_TRUST_REVIEW_PRESET,
LOW_TRUST_REVIEW_PRESET_VERSION,
@@ -269,11 +262,7 @@ import {
telegramStopSubscriptionPlan,
telegramStopSubscriptionConfirmed,
} from "./chat-telegram-stop-subscription.js";
-import {
- nativeFailedRunRetryStateIsSafe,
- nativePreProviderRetryAfterCleanupStateIsSafe,
- nativeProviderRecoveryEvidence,
-} from "./native-runtime/native-session-executor.js";
+
import { parseChatWebhookPublicBaseUrl } from "../chat-webhook-public-url.js";
import {
parseGitHubReceiptIdentity,
@@ -10937,1804 +10926,28 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
]);
}
- const failedChatRetryDenied = () =>
- conflict(
- "The exact failed chat request can no longer be retried safely. Check its current connection, access, and recovery state.",
- { code: "chat_failed_run_retry_not_authorized" },
- );
- class FailedChatRetryPublicationReadError extends Error {
- constructor() {
- super("Chat retry publication authorization is temporarily unavailable");
- }
- }
-
- function retryCommentIds(context: Record): string[] {
- const ids =
- context.wakeCommentIds ??
- (context.wakeCommentId ? [context.wakeCommentId] : []);
- if (
- !Array.isArray(ids) ||
- ids.length === 0 ||
- ids.length > 50 ||
- ids.some((id) => typeof id !== "string" || !isUuidLike(id)) ||
- new Set(ids).size !== ids.length ||
- (context.wakeCommentId !== undefined &&
- context.wakeCommentId !== ids.at(-1))
- )
- throw failedChatRetryDenied();
- return ids as string[];
- }
-
- type FailedChatRetrySource = {
- failedRunId: string;
- sourceWakeupRequestId: string;
- issueId: string;
- agentId: string;
- taskKey: string;
- provider: ChatProvider;
- commentIds: string[];
- sources: Array>;
- endpointId: string;
- conversationId: string;
- principalId: string;
- sessionGeneration: number;
- requestedByActorType: "user" | "system";
- requestedByActorId: string;
- retryAncestors: Array<{
- actionId: string;
- failedRunId: string;
- sourceScopeSha256: string;
- }>;
- sourceScopeSha256: string;
- };
- function hashFailedRetrySource(
- scope: Omit,
- ): FailedChatRetrySource {
- return {
- ...scope,
- sourceScopeSha256: createHash("sha256")
- .update(JSON.stringify(scope))
- .digest("hex"),
- };
- }
-
- function assertFailedRetryReceipt(
- ...args: Parameters
- ) {
- try {
- assertDurableChatWakeupReceipt(...args);
- } catch {
- throw failedChatRetryDenied();
- }
- }
-
- /** The reviewed-chat ownership check runs before runtime/profile resolution
- * or provider startup. Recognize only that exact persisted failure together
- * with its sole system diagnostic and absence of execution evidence. A
- * generic setup error (or a prior-session display hint) is not this proof. */
- async function isPreProviderReviewedChatFailure(
- tx: DbOrTransaction,
- run: typeof heartbeatRuns.$inferSelect,
- ): Promise {
- const diagnostic = "reviewed_chat_execution_binding_not_authorized";
- if (
- run.runtimeMode !== "legacy" ||
- run.errorCode !== "setup_failed" ||
- run.error !== diagnostic ||
- [
- run.runtimeModeResolverVersion,
- run.runtimeModeReason,
- run.runtimeModeResolvedAt,
- run.runnerProfileJson,
- run.runnerInstanceId,
- run.nativeSessionId,
- run.nativeIssueId,
- run.nativePhase,
- run.driverKind,
- run.driverVersion,
- run.completionContractId,
- run.completionContractSha256,
- run.sessionIdAfter,
- run.externalRunId,
- run.processPid,
- run.processGroupId,
- run.processStartedAt,
- run.logStore,
- run.logRef,
- run.logBytes,
- run.logSha256,
- run.stdoutExcerpt,
- run.stderrExcerpt,
- run.lastOutputAt,
- run.lastOutputStream,
- run.lastOutputBytes,
- run.usageJson,
- run.exitCode,
- run.signal,
- ].some((value) => value !== null) ||
- run.lastOutputSeq !== 0 ||
- run.logCompressed
- )
- return false;
- const events = await tx
- .select()
- .from(heartbeatRunEvents)
- .where(
- and(
- eq(heartbeatRunEvents.companyId, run.companyId),
- eq(heartbeatRunEvents.runId, run.id),
- ),
- )
- .limit(2)
- .for("share", { noWait: true });
- const event = events[0];
- if (
- events.length !== 1 ||
- !event ||
- event.agentId !== run.agentId ||
- event.seq !== 1 ||
- event.eventType !== "error" ||
- event.stream !== "system" ||
- event.level !== "error" ||
- event.message !== diagnostic ||
- event.payload !== null ||
- event.sourceInstanceId !== null ||
- event.sourceEventId !== null ||
- event.sourceSeq !== null ||
- event.sourcePayloadSha256 !== null ||
- event.protocolSchemaVersion !== null
- )
- return false;
- const evidence = await tx.execute(sql`select 1 where
- exists (select 1 from native_run_finalizations where company_id = ${run.companyId}::uuid and run_id = ${run.id}::uuid)
- or exists (select 1 from native_run_results where company_id = ${run.companyId}::uuid and run_id = ${run.id}::uuid)
- or exists (select 1 from environment_leases where company_id = ${run.companyId}::uuid and heartbeat_run_id = ${run.id}::uuid)`);
- return evidence.length === 0;
- }
-
- async function failedNativeRetryCoordinator(
- tx: DbOrTransaction,
- run: typeof heartbeatRuns.$inferSelect,
- issueId: string,
- ) {
- const [coordinator] = await tx
- .select()
- .from(nativeRunFinalizations)
- .where(
- and(
- eq(nativeRunFinalizations.runId, run.id),
- eq(nativeRunFinalizations.companyId, run.companyId),
- eq(nativeRunFinalizations.issueId, issueId),
- ),
- )
- .for("share", { noWait: true });
- // A queued request can fail before its first provider event because the
- // predecessor's exact retained checkpoint has not yet been suspended.
- // A later authenticated cleanup receipt can authorize retrying that
- // request; never pretend it exhausted provider execution attempts.
- if (
- coordinator &&
- run.nativeIssueId === issueId &&
- run.status === "failed" &&
- run.errorCode === "adapter_failed" &&
- // A diagnostic reason does not change this failure category. The exact
- // checkpoint, cleanup receipt, and no-provider-work proofs below still
- // decide whether the original request can be retried.
- (run.error === "runner_state_identity_mismatch" ||
- run.error?.startsWith("runner_state_identity_mismatch: ")) &&
- run.nativePhase === "observed" &&
- coordinator.phase === "observed" &&
- coordinator.attempt === 0 &&
- !coordinator.leaseOwner &&
- !coordinator.leaseExpiresAt &&
- !coordinator.nextAttemptAt &&
- !coordinator.resultId &&
- !coordinator.assessmentId &&
- !coordinator.decisionId &&
- run.nativeSessionId &&
- run.processPid &&
- run.processGroupId === run.processPid
- ) {
- const [event] = await tx
- .select({ id: heartbeatRunEvents.id })
- .from(heartbeatRunEvents)
- .where(
- and(
- eq(heartbeatRunEvents.companyId, run.companyId),
- eq(heartbeatRunEvents.runId, run.id),
- or(
- isNotNull(heartbeatRunEvents.sourceInstanceId),
- sql`${heartbeatRunEvents.payload}->'prpEvent' is not null`,
- ),
- ),
- )
- .limit(1);
- const [result] = await tx
- .select({ id: nativeRunResults.id })
- .from(nativeRunResults)
- .where(
- and(
- eq(nativeRunResults.companyId, run.companyId),
- eq(nativeRunResults.runId, run.id),
- ),
- )
- .limit(1);
- const checkpoint = run.runnerProfileJson?.sessionCheckpoint as
- | Record
- | undefined;
- const binding = checkpoint?.identity as
- | Record
- | undefined;
- if (
- !event &&
- !result &&
- binding?.companyId === run.companyId &&
- binding?.issueId === issueId &&
- binding?.agentId === run.agentId &&
- binding?.runId === run.id &&
- binding?.sessionId === run.nativeSessionId &&
- typeof checkpoint?.sessionId === "string" &&
- checkpoint.sessionId.trim().length > 0 &&
- (checkpoint.providerSessionId == null ||
- (typeof checkpoint.providerSessionId === "string" &&
- checkpoint.providerSessionId.trim().length > 0))
- ) {
- const predecessors = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.companyId, run.companyId),
- eq(heartbeatRuns.agentId, run.agentId),
- eq(heartbeatRuns.nativeIssueId, issueId),
- eq(heartbeatRuns.nativeSessionId, run.nativeSessionId),
- eq(heartbeatRuns.processPid, run.processPid),
- eq(heartbeatRuns.processGroupId, run.processGroupId),
- eq(heartbeatRuns.runtimeMode, "native"),
- eq(heartbeatRuns.status, "succeeded"),
- ne(heartbeatRuns.id, run.id),
- // Warm runs can share a PID and session. Only a durable maintenance
- // owner is a candidate; the physical check below must still prove
- // that this exact run owns the canonical suspended checkpoint.
- sql`exists (select 1 from native_run_finalizations retired
- where retired.company_id = ${heartbeatRuns.companyId}
- and retired.run_id = ${heartbeatRuns.id}
- and retired.phase = 'committed'
- and exists (select 1 from jsonb_array_elements(retired.recovery_history) receipt
- where receipt->>'kind' = 'native_cleanup_maintenance'
- and receipt->>'phase' = 'settled' and receipt->>'version' = '1'))`,
- ),
- )
- .for("share", { noWait: true })
- .limit(2);
- if (predecessors.length === 1) {
- const predecessor = predecessors[0]!;
- const [retired] = await tx
- .select()
- .from(nativeRunFinalizations)
- .where(
- and(
- eq(nativeRunFinalizations.companyId, run.companyId),
- eq(nativeRunFinalizations.issueId, issueId),
- eq(nativeRunFinalizations.runId, predecessor.id),
- ),
- )
- .for("share", { noWait: true });
- const receipt = retired?.recoveryHistory.findLast(
- (entry) => entry.kind === "native_cleanup_maintenance",
- );
- const leases = await tx
- .select()
- .from(environmentLeases)
- .where(
- and(
- eq(environmentLeases.companyId, run.companyId),
- eq(environmentLeases.heartbeatRunId, run.id),
- ),
- )
- .for("share", { noWait: true });
- if (
- retired?.phase === "committed" &&
- retired.resultId &&
- retired.assessmentId &&
- retired.decisionId &&
- !retired.leaseOwner &&
- !retired.leaseExpiresAt &&
- !retired.nextAttemptAt &&
- receipt?.version === 1 &&
- receipt.phase === "settled" &&
- typeof receipt.requestId === "string" &&
- receipt.requestId.length > 0 &&
- receipt.nativeSessionId === run.nativeSessionId &&
- receipt.runnerInstanceId === predecessor.runnerInstanceId &&
- // The cleanup receipt identifies the provider thread. The
- // checkpoint's providerSessionId is the separate backend account.
- receipt.providerSessionId === checkpoint.sessionId &&
- typeof receipt.settledFingerprint === "string" &&
- /^[a-f0-9]{64}$/.test(receipt.settledFingerprint) &&
- typeof receipt.sourceFingerprint === "string" &&
- /^[a-f0-9]{64}$/.test(receipt.sourceFingerprint) &&
- leases.every(
- (lease) =>
- lease.provider === "local" &&
- lease.providerLeaseId === null &&
- lease.issueId === issueId &&
- ["failed", "released", "expired"].includes(lease.status) &&
- (lease.cleanupStatus === null ||
- lease.cleanupStatus === "success") &&
- lease.releasedAt !== null,
- )
- ) {
- return {
- coordinator,
- leases,
- retiredOwner: {
- predecessor,
- receipt,
- providerSessionId: checkpoint.sessionId,
- providerBackendSessionId: checkpoint.providerSessionId ?? null,
- },
- };
- }
- }
- }
- }
- const detail = coordinator?.failureDetail;
- const ordinary = [
- "provider_process_exited",
- "provider_stdout_closed",
- "provider_process_output_closed",
- "provider_process_status_failed",
- "provider_initialize_timeout",
- "provider_initialize_protocol_error",
- "provider_request_timeout",
- "provider_request_protocol_error",
- "provider_transport_failed",
- "native_runner_process_exited",
- ];
- if (
- !coordinator ||
- run.nativeIssueId !== issueId ||
- run.nativePhase !== "terminal_failure" ||
- coordinator.phase !== "terminal_failure" ||
- coordinator.recoveryState !== "blocked" ||
- coordinator.failureCode !== "native_session_retry_exhausted" ||
- coordinator.attempt < 3 ||
- coordinator.leaseOwner ||
- coordinator.leaseExpiresAt ||
- coordinator.nextAttemptAt ||
- coordinator.resultId ||
- coordinator.assessmentId ||
- coordinator.decisionId ||
- !detail ||
- !ordinary.includes(String(detail.originalFailureCode)) ||
- !["bootstrap_retry", "exact_checkpoint_resume"].includes(
- String(detail.recoveryMode),
- ) ||
- ![
- "adapter_failed",
- "native_session_retry_exhausted",
- String(detail.originalFailureCode),
- ].includes(run.errorCode ?? "") ||
- (detail.recoveryMode === "bootstrap_retry" &&
- (detail.providerSessionEstablished !== false ||
- detail.providerEventsExist !== false ||
- detail.checkpointExists !== false)) ||
- (detail.recoveryMode === "exact_checkpoint_resume" &&
- (detail.providerSessionEstablished !== true ||
- detail.checkpointExists !== true))
- )
- throw failedChatRetryDenied();
- const evidence = await nativeProviderRecoveryEvidence({
- db: tx as unknown as Db,
- runId: run.id,
- sourceFailureCode: detail.originalFailureCode as Parameters<
- typeof nativeProviderRecoveryEvidence
- >[0]["sourceFailureCode"],
- });
- if (
- evidence.recoveryMode !== detail.recoveryMode ||
- evidence.providerSessionEstablished !==
- detail.providerSessionEstablished ||
- evidence.providerEventsExist !== detail.providerEventsExist ||
- evidence.checkpointExists !== detail.checkpointExists
- )
- throw failedChatRetryDenied();
- const leases = await tx
- .select()
- .from(environmentLeases)
- .where(
- and(
- eq(environmentLeases.companyId, run.companyId),
- eq(environmentLeases.heartbeatRunId, run.id),
- ),
- )
- .for("share", { noWait: true });
- if (
- leases.some(
- (lease) =>
- !["released", "expired", "failed"].includes(lease.status) ||
- lease.cleanupStatus !== "success" ||
- !lease.releasedAt,
- )
- )
- throw failedChatRetryDenied();
- return { coordinator, leases, retiredOwner: null };
- }
-
- async function assertFailedNativeRetryState(
- tx: DbOrTransaction,
- source: Pick,
- companyId: string,
- ) {
- const [run] = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, source.failedRunId),
- eq(heartbeatRuns.companyId, companyId),
- eq(heartbeatRuns.agentId, source.agentId),
- ),
- )
- .for("share", { noWait: true });
- if (!run) throw failedChatRetryDenied();
- if (run.runtimeMode !== "native") return;
- const { coordinator, leases, retiredOwner } =
- await failedNativeRetryCoordinator(tx, run, source.issueId);
- if (retiredOwner) {
- if (
- !nativePreProviderRetryAfterCleanupStateIsSafe({
- failedExecution: run.runnerProfileJson?.nativeExecutionInput,
- retiredExecution:
- retiredOwner.predecessor.runnerProfileJson?.nativeExecutionInput,
- companyId,
- issueId: source.issueId,
- agentId: source.agentId,
- failedRunId: run.id,
- retiredRunId: retiredOwner.predecessor.id,
- nativeSessionId: run.nativeSessionId!,
- runnerInstanceId: retiredOwner.predecessor.runnerInstanceId!,
- providerSessionId: retiredOwner.providerSessionId,
- providerBackendSessionId: retiredOwner.providerBackendSessionId,
- processPid: run.processPid!,
- processGroupId: run.processGroupId!,
- receipt: retiredOwner.receipt,
- })
- )
- throw failedChatRetryDenied();
- return;
- }
- const checkpoint = run.runnerProfileJson?.sessionCheckpoint as
- | Record
- | undefined;
- if (
- !run.nativeSessionId ||
- !run.runnerInstanceId ||
- (checkpoint?.providerSessionId != null &&
- (typeof checkpoint.providerSessionId !== "string" ||
- checkpoint.providerSessionId.trim().length === 0)) ||
- !nativeFailedRunRetryStateIsSafe({
- execution: run.runnerProfileJson?.nativeExecutionInput,
- companyId,
- issueId: source.issueId,
- agentId: source.agentId,
- runId: run.id,
- nativeSessionId: run.nativeSessionId,
- runnerInstanceId: run.runnerInstanceId,
- processPid: run.processPid,
- processGroupId: run.processGroupId,
- providerSessionId:
- typeof checkpoint?.sessionId === "string"
- ? checkpoint.sessionId
- : null,
- providerBackendSessionId:
- typeof checkpoint?.providerSessionId === "string"
- ? checkpoint.providerSessionId
- : null,
- recoveryMode: coordinator.failureDetail!.recoveryMode as
- | "bootstrap_retry"
- | "exact_checkpoint_resume",
- allowVerifiedBackup: leases.length > 0,
- })
- )
- throw failedChatRetryDenied();
- }
-
- /** Resolve only a complete, originally admitted direct-chat batch. A retry
- * never turns caller JSON, task-description text, or a provider redelivery
- * into new input authority. Answer continuations require their own proof. */
- async function failedChatRetrySource(
- tx: DbOrTransaction,
- input: {
- companyId: string;
- issueId: string;
- agentId: string;
- failedRunId: string;
- retryActionId?: string;
- runId?: string;
- publication?: boolean;
- /** Internal selector for already committed output, never retry admission. */
- committedResponse?: {
- resultId: string;
- canonicalSha256?: string;
- decisionId?: string;
- /** Internal source-proof continuation for one locked Teams transfer.
- * Never permission to repeat its card/PUT or retry a model run. */
- continuingTeamsFileTransfer?: {
- transferId: string;
- publicationId: string;
- version: number;
- phase: "consent_unknown" | "file_info_unknown";
- };
- };
- },
- visited = new Set(),
- ): Promise {
- if (visited.has(input.failedRunId) || visited.size >= 16)
- throw failedChatRetryDenied();
- const ancestors = new Set([...visited, input.failedRunId]);
- const [issue] = await tx
- .select()
- .from(issues)
- .where(
- and(
- eq(issues.id, input.issueId),
- eq(issues.companyId, input.companyId),
- ),
- )
- .for(
- "update",
- input.publication || input.retryActionId ? { noWait: true } : undefined,
- );
- const [actor] = await tx
- .select({ status: agents.status })
- .from(agents)
- .where(
- and(
- eq(agents.id, input.agentId),
- eq(agents.companyId, input.companyId),
- ),
- )
- .for("share", { noWait: true });
- const [failedRun] = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, input.failedRunId),
- eq(heartbeatRuns.companyId, input.companyId),
- eq(heartbeatRuns.agentId, input.agentId),
- ),
- )
- .for("share", { noWait: true });
- if (
- !issue ||
- issue.assigneeAgentId !== input.agentId ||
- !actor ||
- !isAgentStatusInvokable(actor.status) ||
- !failedRun ||
- !failedRun.wakeupRequestId ||
- (!input.committedResponse &&
- (!["failed", "timed_out"].includes(failedRun.status) ||
- !failedRun.finishedAt)) ||
- (!input.committedResponse &&
- failedRun.runtimeMode !== "native" &&
- !(
- [
- "adapter_failed",
- "adapter_exit_code",
- "process_exit",
- "timeout",
- ].includes(failedRun.errorCode ?? "") ||
- (await isPreProviderReviewedChatFailure(tx, failedRun))
- )) ||
- (!input.publication &&
- ["backlog", "done", "cancelled"].includes(issue.status))
- )
- throw failedChatRetryDenied();
- if (failedRun.runtimeMode === "native" && !input.committedResponse)
- await failedNativeRetryCoordinator(tx, failedRun, issue.id);
- const context = failedRun.contextSnapshot ?? {};
- const provider = CHAT_PROVIDERS.find(
- (value) => context.source === `chat:${value}`,
- );
- if (
- !provider ||
- context.issueId !== issue.id ||
- context.interactionId ||
- (context.taskKey !== issue.identifier && context.taskKey !== issue.id)
- )
- throw failedChatRetryDenied();
- const commentIds = retryCommentIds(context);
- const [accepted] = await tx
- .select()
- .from(nativeRunResults)
- .where(
- and(
- eq(nativeRunResults.companyId, input.companyId),
- eq(nativeRunResults.runId, failedRun.id),
- eq(nativeRunResults.schemaStatus, "accepted"),
- input.committedResponse
- ? eq(nativeRunResults.id, input.committedResponse.resultId)
- : undefined,
- ),
- )
- .limit(1)
- .for("share", { noWait: true });
- if (input.committedResponse) {
- const [coordinator] = await tx
- .select()
- .from(nativeRunFinalizations)
- .where(
- and(
- eq(nativeRunFinalizations.companyId, input.companyId),
- eq(nativeRunFinalizations.issueId, issue.id),
- eq(nativeRunFinalizations.runId, failedRun.id),
- ),
- )
- .for("share", { noWait: true });
- const result = accepted?.resultJson.result as
- | Record
- | undefined;
- const terminal = accepted?.resultJson.terminal as
- | Record
- | undefined;
- const continuation = result?.continuation as
- | Record
- | undefined;
- if (
- failedRun.runtimeMode !== "native" ||
- failedRun.nativeIssueId !== issue.id ||
- !accepted ||
- accepted.id !== input.committedResponse.resultId ||
- accepted.issueId !== issue.id ||
- accepted.completionContractId !== failedRun.completionContractId ||
- coordinator?.phase !== "committed" ||
- coordinator.resultId !== accepted.id ||
- !coordinator.decisionId ||
- !coordinator.assessmentId ||
- (input.committedResponse.canonicalSha256 !== undefined &&
- input.committedResponse.canonicalSha256 !==
- accepted.canonicalSha256) ||
- (input.committedResponse.decisionId !== undefined &&
- input.committedResponse.decisionId !== coordinator.decisionId) ||
- result?.schema !== "paperclip.run_result.v1" ||
- result.reportedWorkDisposition !== "yielded" ||
- !Array.isArray(result.attentionRequests) ||
- result.attentionRequests.length !== 0 ||
- continuation?.kind !== "response_wake" ||
- terminal?.schema !== "paperclip.prp.terminal.v1" ||
- terminal.turnTerminalState !== "completed" ||
- terminal.runTerminalState !== "succeeded" ||
- terminal.reportedWorkDisposition !== "yielded"
- )
- throw failedChatRetryDenied();
- }
- let continuingUnknownPublicationId: string | undefined;
- const continuingTransfer =
- input.committedResponse?.continuingTeamsFileTransfer;
- if (continuingTransfer !== undefined) {
- if (
- input.publication !== true ||
- input.retryActionId !== undefined ||
- input.runId !== undefined ||
- provider !== "microsoft-teams" ||
- !continuingTransfer ||
- !isUuidLike(continuingTransfer.transferId) ||
- !isUuidLike(continuingTransfer.publicationId) ||
- !Number.isSafeInteger(continuingTransfer.version) ||
- continuingTransfer.version < 1 ||
- !["consent_unknown", "file_info_unknown"].includes(
- continuingTransfer.phase,
- )
- )
- throw failedChatRetryDenied();
- // The caller supplies a selector only. Independently prove the exact
- // unknown publication is this native run's own selected file, not a
- // sibling effect or another actor/generation. All ordinary source and
- // committed-result authorization below still runs unchanged.
- const [continuing] = await tx
- .select({
- publicationId: chatPublications.id,
- attemptId: chatTeamsFileTransfers.attemptId,
- attemptExpiresAt: chatTeamsFileTransfers.attemptExpiresAt,
- })
- .from(chatTeamsFileTransfers)
- .innerJoin(
- chatPublications,
- and(
- eq(chatPublications.id, chatTeamsFileTransfers.publicationId),
- eq(chatPublications.companyId, chatTeamsFileTransfers.companyId),
- eq(chatPublications.endpointId, chatTeamsFileTransfers.endpointId),
- eq(
- chatPublications.conversationId,
- chatTeamsFileTransfers.conversationId,
- ),
- eq(chatPublications.issueId, chatTeamsFileTransfers.issueId),
- eq(chatPublications.commentId, chatTeamsFileTransfers.commentId),
- eq(chatPublications.state, "delivery_unknown"),
- sql`${chatPublications.payload}->'attachmentIds' = jsonb_build_array(${chatTeamsFileTransfers.attachmentId}::text)`,
- ),
- )
- .innerJoin(
- chatEndpoints,
- and(
- eq(chatEndpoints.id, chatTeamsFileTransfers.endpointId),
- eq(chatEndpoints.companyId, input.companyId),
- eq(chatEndpoints.provider, "microsoft-teams"),
- ),
- )
- .innerJoin(
- chatConversations,
- and(
- eq(chatConversations.id, chatTeamsFileTransfers.conversationId),
- eq(chatConversations.companyId, input.companyId),
- eq(chatConversations.endpointId, chatTeamsFileTransfers.endpointId),
- eq(chatConversations.issueId, issue.id),
- eq(
- chatConversations.sessionGeneration,
- chatTeamsFileTransfers.conversationGeneration,
- ),
- ),
- )
- .innerJoin(
- issueComments,
- and(
- eq(issueComments.id, chatTeamsFileTransfers.commentId),
- eq(issueComments.companyId, input.companyId),
- eq(issueComments.issueId, issue.id),
- eq(issueComments.createdByRunId, failedRun.id),
- eq(issueComments.authorAgentId, input.agentId),
- isNull(issueComments.deletedAt),
- ),
- )
- .innerJoin(
- issueAttachments,
- and(
- eq(issueAttachments.id, chatTeamsFileTransfers.attachmentId),
- eq(issueAttachments.companyId, input.companyId),
- eq(issueAttachments.issueId, issue.id),
- eq(issueAttachments.issueCommentId, issueComments.id),
- eq(issueAttachments.originatingRunId, failedRun.id),
- ),
- )
- .innerJoin(
- assets,
- and(
- eq(assets.id, issueAttachments.assetId),
- eq(assets.companyId, input.companyId),
- eq(assets.sha256, chatTeamsFileTransfers.sha256),
- eq(assets.byteSize, chatTeamsFileTransfers.byteSize),
- ),
- )
- .where(
- and(
- eq(chatTeamsFileTransfers.companyId, input.companyId),
- eq(chatTeamsFileTransfers.issueId, issue.id),
- eq(chatTeamsFileTransfers.id, continuingTransfer.transferId),
- eq(
- chatTeamsFileTransfers.publicationId,
- continuingTransfer.publicationId,
- ),
- eq(chatTeamsFileTransfers.version, continuingTransfer.version),
- eq(chatTeamsFileTransfers.phase, continuingTransfer.phase),
- ),
- )
- .for("share", { noWait: true });
- if (
- !continuing ||
- (continuing.attemptId &&
- (!continuing.attemptExpiresAt ||
- continuing.attemptExpiresAt > new Date()))
- )
- throw failedChatRetryDenied();
- continuingUnknownPublicationId = continuing.publicationId;
- }
- const [question] = await tx
- .select({ id: issueThreadInteractions.id })
- .from(issueThreadInteractions)
- .where(
- and(
- eq(issueThreadInteractions.companyId, input.companyId),
- eq(issueThreadInteractions.issueId, issue.id),
- eq(issueThreadInteractions.sourceRunId, failedRun.id),
- ),
- )
- .limit(1);
- const [answerDelivery] = await tx
- .select({ id: issueQuestionResponseDeliveries.id })
- .from(issueQuestionResponseDeliveries)
- .where(
- and(
- eq(issueQuestionResponseDeliveries.companyId, input.companyId),
- eq(issueQuestionResponseDeliveries.targetRunId, failedRun.id),
- ),
- )
- .limit(1);
- const [resultPublication] = await tx
- .select({ id: chatPublications.id })
- .from(chatPublications)
- .where(
- and(
- eq(chatPublications.companyId, input.companyId),
- eq(chatPublications.issueId, issue.id),
- input.committedResponse
- ? eq(chatPublications.state, "delivery_unknown")
- : undefined,
- continuingUnknownPublicationId
- ? ne(chatPublications.id, continuingUnknownPublicationId)
- : undefined,
- or(
- and(
- like(chatPublications.idempotencyKey, `run:${failedRun.id}:%`),
- eq(chatPublications.state, "delivery_unknown"),
- ),
- sql`exists (select 1 from issue_comments result_comment where result_comment.id = ${chatPublications.commentId}
- and result_comment.company_id = ${input.companyId}::uuid and result_comment.created_by_run_id = ${failedRun.id}::uuid
- )`,
- ),
- ),
- )
- .limit(1);
- if (
- (!input.committedResponse && accepted) ||
- resultPublication ||
- question ||
- answerDelivery
- )
- throw failedChatRetryDenied();
- if (context.chatFailedRunRetry) {
- const hint = context.chatFailedRunRetry as Record;
- const [priorIntent] = await tx
- .select()
- .from(chatActions)
- .where(
- and(
- eq(chatActions.id, failedRun.wakeupRequestId),
- eq(chatActions.companyId, input.companyId),
- ),
- )
- .for("share", { noWait: true });
- const [priorReceipt] = await tx
- .select()
- .from(agentWakeupRequests)
- .where(
- and(
- eq(agentWakeupRequests.id, failedRun.wakeupRequestId),
- eq(agentWakeupRequests.companyId, input.companyId),
- ),
- )
- .for("share", { noWait: true });
- if (
- !priorIntent ||
- priorIntent.kind !== "failed_run_retry" ||
- priorIntent.status !== "processed" ||
- !priorReceipt ||
- priorReceipt.runId !== failedRun.id ||
- !["failed", "completed"].includes(priorReceipt.status) ||
- typeof priorIntent.payload.failedRunId !== "string" ||
- !isUuidLike(priorIntent.payload.failedRunId) ||
- hint.version !== 1 ||
- hint.actionId !== priorIntent.id ||
- hint.failedRunId !== priorIntent.payload.failedRunId ||
- failedRun.retryOfRunId !== priorIntent.payload.failedRunId ||
- context.retryOfRunId !== failedRun.retryOfRunId
- )
- throw failedChatRetryDenied();
- const parent = await failedChatRetrySource(
- tx,
- {
- ...input,
- failedRunId: priorIntent.payload.failedRunId,
- committedResponse: undefined,
- },
- ancestors,
- );
- if (
- priorIntent.payload.sourceScopeSha256 !== parent.sourceScopeSha256 ||
- priorIntent.endpointId !== parent.endpointId ||
- priorIntent.conversationId !== parent.conversationId ||
- priorIntent.principalId !== parent.principalId ||
- context.taskKey !== parent.taskKey ||
- provider !== parent.provider ||
- JSON.stringify(commentIds) !== JSON.stringify(parent.commentIds)
- )
- throw failedChatRetryDenied();
- assertFailedRetryReceipt(
- createDurableChatWakeupRequest({
- id: priorIntent.id,
- companyId: input.companyId,
- agentId: input.agentId,
- issueId: input.issueId,
- commentId: parent.commentIds.at(-1)!,
- requestedByActorType: parent.requestedByActorType,
- requestedByActorId: parent.requestedByActorId,
- requestedAt: priorIntent.createdAt,
- authorize: async () => {},
- }),
- priorReceipt,
- );
- const { sourceScopeSha256, ...parentScope } = parent;
- return hashFailedRetrySource({
- ...parentScope,
- failedRunId: failedRun.id,
- retryAncestors: [
- ...parent.retryAncestors,
- {
- actionId: priorIntent.id,
- failedRunId: parent.failedRunId,
- sourceScopeSha256,
- },
- ],
- });
- }
- // Include every receipt that fed this run, including a deferred owner's
- // coalesced children. A mutable latest-comment hint cannot drop a sibling.
- const admittedBatch =
- await tx.execute(sql`select source.id, source.payload->>'commentId' as comment_id
- from agent_wakeup_requests receipt join chat_actions source on source.id = receipt.id
- where receipt.company_id = ${input.companyId}::uuid and receipt.agent_id = ${input.agentId}::uuid
- and (receipt.run_id = ${failedRun.id}::uuid or receipt.id = ${failedRun.wakeupRequestId}::uuid
- or receipt.payload->>'coalescedIntoWakeupRequestId' = ${failedRun.wakeupRequestId})
- and source.company_id = ${input.companyId}::uuid and source.kind = 'inbound_wakeup' limit 51`);
- if (
- admittedBatch.length !== commentIds.length ||
- admittedBatch.some(
- (entry) => !commentIds.includes(String(entry.comment_id)),
- )
- )
- throw failedChatRetryDenied();
- const sources = await tx
- .select()
- .from(chatActions)
- .where(
- and(
- eq(chatActions.companyId, input.companyId),
- eq(chatActions.kind, "inbound_wakeup"),
- sql`${chatActions.payload}->>'issueId' = ${issue.id}`,
- inArray(
- sql`${chatActions.payload}->>'commentId'`,
- commentIds,
- ),
- ),
- );
- if (sources.length !== commentIds.length) throw failedChatRetryDenied();
- const ordered = commentIds.map(
- (id) => sources.find((action) => action.payload.commentId === id)!,
- );
- const first = ordered[0]!;
- if (
- !first ||
- !first.principalId ||
- !first.conversationId ||
- sources.some(
- (action) =>
- action.endpointId !== first.endpointId ||
- action.conversationId !== first.conversationId ||
- action.principalId !== first.principalId ||
- action.payload.requestedByActorType !==
- first.payload.requestedByActorType ||
- action.payload.requestedByActorId !==
- first.payload.requestedByActorId,
- )
- )
- throw failedChatRetryDenied();
- const identity = await tx.execute(
- sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${input.companyId}:${first.principalId}`}, 0)) as locked`,
- );
- if (!identity[0]?.locked)
- throw new NativeChatReviewPresentationContentionError();
- const canonicalSources: Array> = [];
- let destination: Awaited> | null =
- null;
- for (const action of ordered) {
- destination = await authorizeInboundWakeup(tx, action, {
- nonblocking: true,
- terminal: input.publication === true,
- }).catch((error) => {
- if (isExternalActionAuthorizationChange(error))
- throw failedChatRetryDenied();
- throw error;
- });
- const [delivery] = await tx
- .select()
- .from(chatDeliveries)
- .where(eq(chatDeliveries.id, action.deliveryId!))
- .for("update", { noWait: true });
- const [current] = await tx
- .select()
- .from(chatActions)
- .where(eq(chatActions.id, action.id))
- .for("update", { noWait: true });
- const [comment] = await tx
- .select()
- .from(issueComments)
- .where(
- and(
- eq(issueComments.id, String(action.payload.commentId)),
- eq(issueComments.companyId, input.companyId),
- eq(issueComments.issueId, issue.id),
- ),
- )
- .for("share", { noWait: true });
- if (
- !current ||
- current.status !== "processed" ||
- inboundWakeScope(current) !== inboundWakeScope(action) ||
- !delivery ||
- delivery.state !== "processed" ||
- !comment ||
- comment.deletedAt ||
- comment.updatedAt.getTime() !== comment.createdAt.getTime() ||
- comment.updatedAt >
- (input.committedResponse
- ? accepted!.createdAt
- : failedRun.finishedAt!) ||
- destination.endpoint.provider !== provider ||
- JSON.stringify(lifecycleRuntimeFence(delivery)) !==
- JSON.stringify(lifecycleRuntimeFence(destination.delivery))
- )
- throw failedChatRetryDenied();
- const [lifecycle] = await tx
- .select({ id: chatDeliveries.id })
- .from(chatDeliveries)
- .where(
- and(
- eq(chatDeliveries.companyId, input.companyId),
- eq(chatDeliveries.endpointId, first.endpointId),
- or(
- isNull(chatDeliveries.conversationId),
- eq(chatDeliveries.conversationId, first.conversationId),
- ),
- ne(chatDeliveries.state, "filtered"),
- sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(delivery))}::jsonb`,
- inArray(chatDeliveries.eventKind, [
- "message_updated",
- "message_deleted",
- "message_restored",
- ]),
- sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`,
- ),
- )
- .limit(1);
- if (lifecycle) throw failedChatRetryDenied();
- const [receipt] = await tx
- .select()
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, action.id))
- .for("share", { noWait: true });
- if (!receipt) throw failedChatRetryDenied();
- assertFailedRetryReceipt(
- createDurableChatWakeupRequest({
- id: action.id,
- companyId: input.companyId,
- agentId: input.agentId,
- issueId: issue.id,
- commentId: comment.id,
- requestedByActorType: action.payload.requestedByActorType as
- | "user"
- | "system",
- requestedByActorId: String(action.payload.requestedByActorId),
- requestedAt: action.createdAt,
- authorize: async () => {},
- }),
- receipt,
- );
- let owner = receipt;
- const seen = new Set();
- for (
- let depth = 0;
- owner.payload?.coalescedIntoWakeupRequestId !== undefined;
- depth++
- ) {
- const ownerId = owner.payload.coalescedIntoWakeupRequestId;
- // The scheduler writes a direct child -> canonical owner edge. Nested
- // chains/cycles are not silently interpreted as additional authority.
- if (
- depth >= 1 ||
- owner.status !== "coalesced" ||
- typeof ownerId !== "string" ||
- !isUuidLike(ownerId) ||
- seen.has(ownerId)
- )
- throw failedChatRetryDenied();
- seen.add(ownerId);
- const [next] = await tx
- .select()
- .from(agentWakeupRequests)
- .where(
- and(
- eq(agentWakeupRequests.id, ownerId),
- eq(agentWakeupRequests.companyId, input.companyId),
- ),
- )
- .for("share", { noWait: true });
- if (!next) throw failedChatRetryDenied();
- owner = next;
- }
- if (
- owner.id !== failedRun.wakeupRequestId ||
- owner.runId !== failedRun.id ||
- owner.agentId !== input.agentId ||
- owner.requestedByActorType !== receipt.requestedByActorType ||
- owner.requestedByActorId !== receipt.requestedByActorId ||
- ["skipped", "cancelled"].includes(receipt.status)
- )
- throw failedChatRetryDenied();
- canonicalSources.push({
- actionId: action.id,
- deliveryId: delivery.id,
- commentId: comment.id,
- principalId: action.principalId,
- scope: inboundWakeScope(action),
- fence: lifecycleRuntimeFence(delivery),
- bodySha256: createHash("sha256").update(comment.body).digest("hex"),
- omissionReasons: action.payload.attachmentOmissionReasons ?? {},
- receiptId: receipt.id,
- ownerId: owner.id,
- });
- }
- if (!destination) throw failedChatRetryDenied();
- const newestSource = ordered.reduce(
- (latest, action) =>
- action.createdAt > latest ? action.createdAt : latest,
- first.createdAt,
- );
- const [newer] = await tx
- .select({ id: chatActions.id })
- .from(chatActions)
- .where(
- and(
- eq(chatActions.companyId, input.companyId),
- eq(chatActions.conversationId, first.conversationId),
- eq(chatActions.kind, "inbound_wakeup"),
- notInArray(
- chatActions.id,
- ordered.map((action) => action.id),
- ),
- gte(chatActions.createdAt, newestSource),
- sql`exists (select 1 from agent_wakeup_requests newer_admission where newer_admission.id = ${chatActions.id}
- and newer_admission.company_id = ${input.companyId}::uuid and newer_admission.status <> 'skipped')`,
- ),
- )
- .limit(1);
- if (newer && !input.publication) throw failedChatRetryDenied();
- const scope = {
- failedRunId: failedRun.id,
- sourceWakeupRequestId: failedRun.wakeupRequestId,
- issueId: issue.id,
- agentId: input.agentId,
- taskKey: context.taskKey as string,
- provider,
- commentIds,
- sources: canonicalSources,
- endpointId: first.endpointId,
- conversationId: first.conversationId,
- principalId: first.principalId,
- sessionGeneration: destination.conversation.sessionGeneration,
- requestedByActorType: first.payload.requestedByActorType as
- | "user"
- | "system",
- requestedByActorId: String(first.payload.requestedByActorId),
- retryAncestors: [],
- };
- return hashFailedRetrySource(scope);
- }
-
- const unregisterCommittedResponseAuthority =
- registerCommittedChatResponseAuthority(db, async (tx, input) => {
- try {
- await failedChatRetrySource(tx, {
- ...input,
- failedRunId: input.runId,
- publication: true,
- committedResponse: { resultId: input.resultId },
- });
- } catch (error) {
- if (
- error instanceof HttpError &&
- (error.details as { code?: string } | undefined)?.code ===
- "chat_failed_run_retry_not_authorized"
- ) {
- throw new CommittedChatResponseAuthorizationError();
- }
- throw error;
- }
- });
-
- async function prepareFailedChatRunRetry(
- tx: DbOrTransaction,
- input: {
- companyId: string;
- issueId: string;
- agentId: string;
- failedRunId: string;
- initiatedByUserId: string;
- },
- ): Promise<{ actionId: string; issueId: string }> {
- const source = await failedChatRetrySource(tx, input);
- const providerActionId = `failed-run-retry:${source.failedRunId}`;
- const [existing] = await tx
- .select()
- .from(chatActions)
- .where(
- and(
- eq(chatActions.endpointId, source.endpointId),
- eq(chatActions.providerActionId, providerActionId),
- ),
- )
- .for("update", { noWait: true });
- if (existing) {
- if (
- existing.kind !== "failed_run_retry" ||
- existing.companyId !== input.companyId ||
- existing.payload.sourceScopeSha256 !== source.sourceScopeSha256
- )
- throw failedChatRetryDenied();
- return { actionId: existing.id, issueId: input.issueId };
- }
- await assertFailedNativeRetryState(tx, source, input.companyId);
- const [busy] = await tx
- .select({ id: heartbeatRuns.id })
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.companyId, input.companyId),
- inArray(heartbeatRuns.status, [
- "queued",
- "running",
- "scheduled_retry",
- ]),
- sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`,
- ),
- )
- .limit(1);
- if (busy || !input.initiatedByUserId) throw failedChatRetryDenied();
- const now = new Date();
- const [intent] = await tx
- .insert(chatActions)
- .values({
- companyId: input.companyId,
- endpointId: source.endpointId,
- conversationId: source.conversationId,
- principalId: source.principalId,
- kind: "failed_run_retry",
- providerActionId,
- payload: {
- version: 1,
- ...source,
- initiatedByUserId: input.initiatedByUserId,
- },
- status: "issued",
- result: { code: "failed_chat_retry_queued", attemptCount: 0 },
- createdAt: now,
- updatedAt: now,
- })
- .returning();
- await logActivity(tx as unknown as Db, {
- companyId: input.companyId,
- actorType: "user",
- actorId: input.initiatedByUserId,
- action: "chat.failed_run_retry_requested",
- entityType: "heartbeat_run",
- entityId: input.failedRunId,
- details: {
- retryActionId: intent.id,
- issueId: input.issueId,
- commentCount: source.commentIds.length,
- },
- });
- return { actionId: intent.id, issueId: input.issueId };
- }
-
- async function authorizeFailedChatRunRetry(
- tx: DbOrTransaction,
- input: {
- companyId: string;
- issueId: string;
- agentId: string;
- retryActionId: string;
- runId?: string;
- publication?: boolean;
- },
- ) {
- const [snapshot] = await tx
- .select()
- .from(chatActions)
- .where(
- and(
- eq(chatActions.id, input.retryActionId),
- eq(chatActions.companyId, input.companyId),
- ),
- );
- if (
- !snapshot ||
- snapshot.kind !== "failed_run_retry" ||
- typeof snapshot.payload.failedRunId !== "string" ||
- snapshot.payload.issueId !== input.issueId ||
- snapshot.payload.agentId !== input.agentId
- )
- throw failedChatRetryDenied();
- const source = await failedChatRetrySource(tx, {
- ...input,
- failedRunId: snapshot.payload.failedRunId,
- });
- const [intent] = await tx
- .select()
- .from(chatActions)
- .where(eq(chatActions.id, snapshot.id))
- .for("update", { noWait: true });
- if (
- !intent ||
- !(
- input.publication
- ? ["issued", "processing", "processed", "failed", "cancelled"]
- : ["issued", "processing", "processed"]
- ).includes(intent.status) ||
- intent.payload.version !== 1 ||
- intent.endpointId !== source.endpointId ||
- intent.conversationId !== source.conversationId ||
- intent.principalId !== source.principalId ||
- intent.payload.sourceScopeSha256 !== source.sourceScopeSha256 ||
- JSON.stringify(intent.payload) !== JSON.stringify(snapshot.payload)
- )
- throw failedChatRetryDenied();
- const [receipt] = await tx
- .select()
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, intent.id))
- .for("share", { noWait: true });
- if (receipt) {
- assertFailedRetryReceipt(
- createDurableChatWakeupRequest({
- id: intent.id,
- companyId: input.companyId,
- agentId: input.agentId,
- issueId: input.issueId,
- commentId: source.commentIds.at(-1)!,
- requestedByActorType: source.requestedByActorType,
- requestedByActorId: source.requestedByActorId,
- requestedAt: intent.createdAt,
- authorize: async () => {},
- }),
- receipt,
- );
- if (
- (!input.publication &&
- ["skipped", "cancelled"].includes(receipt.status)) ||
- (input.runId && receipt.runId !== input.runId)
- )
- throw failedChatRetryDenied();
- } else if (input.runId) throw failedChatRetryDenied();
- if (input.runId) {
- const [run] = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, input.runId),
- eq(heartbeatRuns.companyId, input.companyId),
- eq(heartbeatRuns.agentId, input.agentId),
- ),
- )
- .for("share", { noWait: true });
- if (
- !run ||
- run.wakeupRequestId !== intent.id ||
- run.retryOfRunId !== source.failedRunId
- )
- throw failedChatRetryDenied();
- }
- return { ...source, intent };
- }
-
- const unregisterFailedRetryAuthority = registerFailedChatRunRetryAuthority(
- db,
- async (tx, input) => {
- const source = await authorizeFailedChatRunRetry(tx, {
- ...input,
- retryActionId: input.wakeupRequestId,
- });
- await assertFailedNativeRetryState(tx, source, input.companyId);
- const context = input.contextSnapshot;
- const hint = context.chatFailedRunRetry as
- | Record
- | undefined;
- if (
- context.issueId !== source.issueId ||
- context.source !== `chat:${source.provider}` ||
- context.taskKey !== source.taskKey ||
- context.retryOfRunId !== source.failedRunId ||
- JSON.stringify(retryCommentIds(context)) !==
- JSON.stringify(source.commentIds) ||
- hint?.version !== 1 ||
- hint.actionId !== source.intent.id ||
- hint.failedRunId !== source.failedRunId ||
- !Array.isArray(context.externalAttachmentOmissions) ||
- JSON.stringify(
- context.externalAttachmentOmissions.map((entry) =>
- entry && typeof entry === "object"
- ? [entry.commentId, entry.reasons]
- : null,
- ),
- ) !==
- JSON.stringify(
- source.sources.map((entry) => [
- entry.commentId,
- entry.omissionReasons,
- ]),
- )
- )
- throw failedChatRetryDenied();
- },
- );
-
- async function failedChatRetryReceipt(
- action: typeof chatActions.$inferSelect,
- ) {
- const [receipt] = await db
- .select()
- .from(agentWakeupRequests)
- .where(
- and(
- eq(agentWakeupRequests.id, action.id),
- eq(agentWakeupRequests.companyId, action.companyId),
- ),
- );
- const [run] = receipt?.runId
- ? await db
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, receipt.runId),
- eq(heartbeatRuns.companyId, action.companyId),
- ),
- )
- : [];
- const status = ["succeeded", "failed", "cancelled"].includes(
- run?.status ?? "",
- )
- ? (run!.status as "succeeded" | "failed" | "cancelled")
- : run?.status === "timed_out"
- ? "failed"
- : run?.status === "running"
- ? "running"
- : receipt?.status === "deferred_issue_execution"
- ? "deferred"
- : ["failed", "cancelled"].includes(action.status)
- ? (action.status as "failed" | "cancelled")
- : ["skipped", "failed", "cancelled"].includes(
- receipt?.status ?? "",
- )
- ? "failed"
- : "queued";
- return {
- actionId: action.id,
- issueId: String(action.payload.issueId),
- runId: run?.id ?? null,
- status,
- };
- }
-
- async function claimFailedChatRunRetry(actionId: string) {
- const [candidate] = await db
- .select()
- .from(chatActions)
- .where(
- and(
- eq(chatActions.id, actionId),
- eq(chatActions.kind, "failed_run_retry"),
- ),
- );
- if (!candidate) throw notFound("Chat retry request not found");
- const now = new Date();
- if (
- shuttingDown ||
- !["issued", "processing"].includes(candidate.status) ||
- (candidate.status === "processing" &&
- candidate.updatedAt.getTime() >
- now.getTime() - DELIVERY_PROCESSING_STALE_MS) ||
- (typeof candidate.result?.retryAt === "string" &&
- Date.parse(candidate.result.retryAt) > now.getTime())
- )
- return failedChatRetryReceipt(candidate);
- const [claimed] = await db
- .update(chatActions)
- .set({ status: "processing", updatedAt: now })
- .where(
- and(
- eq(chatActions.id, candidate.id),
- eq(chatActions.status, candidate.status),
- eq(chatActions.updatedAt, candidate.updatedAt),
- ),
- )
- .returning();
- if (!claimed) return failedChatRetryReceipt(candidate);
- const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
- const settle = async (status: string, result: Record) => {
- const [current] = await db
- .update(chatActions)
- .set({ status, result, updatedAt: new Date() })
- .where(
- and(
- eq(chatActions.id, claimed.id),
- eq(chatActions.status, "processing"),
- eq(chatActions.updatedAt, now),
- ),
- )
- .returning();
- return failedChatRetryReceipt(current ?? claimed);
- };
- try {
- const scope = {
- companyId: claimed.companyId,
- issueId: String(claimed.payload.issueId),
- agentId: String(claimed.payload.agentId),
- retryActionId: claimed.id,
- };
- const source = await db.transaction((tx) =>
- authorizeFailedChatRunRetry(tx, scope),
- );
- const existing = await db
- .select({ id: agentWakeupRequests.id })
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, claimed.id));
- if (!existing.length) {
- const context = {
- issueId: source.issueId,
- taskKey: source.taskKey,
- source: `chat:${source.provider}`,
- wakeCommentId: source.commentIds.at(-1)!,
- wakeCommentIds: source.commentIds,
- retryOfRunId: source.failedRunId,
- chatFailedRunRetry: {
- version: 1,
- actionId: claimed.id,
- failedRunId: source.failedRunId,
- },
- externalAttachmentOmissions: source.sources.map((entry) => ({
- commentId: entry.commentId,
- reasons: entry.omissionReasons,
- })),
- };
- await options.heartbeat.wakeup(source.agentId, {
- source: "on_demand",
- triggerDetail: "manual",
- reason: "retry_failed_run",
- payload: context,
- contextSnapshot: context,
- allowRunCoalescing: false,
- requestedByActorType: source.requestedByActorType,
- requestedByActorId: source.requestedByActorId,
- durableChatRequest: createDurableChatWakeupRequest({
- id: claimed.id,
- companyId: claimed.companyId,
- agentId: source.agentId,
- issueId: source.issueId,
- commentId: source.commentIds.at(-1)!,
- requestedByActorType: source.requestedByActorType,
- requestedByActorId: source.requestedByActorId,
- requestedAt: claimed.createdAt,
- failedRunRetry: { failedRunId: source.failedRunId },
- authorize: async (tx) => {
- const current = await authorizeFailedChatRunRetry(tx, scope);
- await assertFailedNativeRetryState(
- tx,
- current,
- claimed.companyId,
- );
- },
- }),
- });
- }
- const [receipt] = await db
- .select()
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, claimed.id));
- if (!receipt) throw new Error("chat_retry_receipt_not_committed");
- return settle(
- ["skipped", "failed", "cancelled"].includes(receipt.status)
- ? "failed"
- : "processed",
- { code: "failed_chat_retry_durable", attemptCount },
- );
- } catch (error) {
- const [receipt] = await db
- .select()
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, claimed.id));
- if (receipt)
- return settle("processed", {
- code: "failed_chat_retry_durable",
- attemptCount,
- });
- const denied =
- error &&
- typeof error === "object" &&
- "details" in error &&
- error.details &&
- typeof error.details === "object" &&
- "code" in error.details &&
- [
- "chat_failed_run_retry_not_authorized",
- "chat_action_authorization_changed",
- ].includes(String(error.details.code));
- const retryable = !denied && attemptCount < 8;
- return settle(retryable ? "issued" : "failed", {
- code: retryable
- ? "failed_chat_retry_contended"
- : "failed_chat_retry_not_authorized",
- attemptCount,
- ...(retryable
- ? {
- retryAt: new Date(
- Date.now() + Math.min(5_000, attemptCount * 250),
- ).toISOString(),
- }
- : {}),
- });
- }
- }
-
- const failedRetryTasks = new Map<
- string,
- ReturnType
- >();
- function processFailedChatRunRetry(actionId: string) {
- const existing = failedRetryTasks.get(actionId);
- if (existing) return existing;
- const task = claimFailedChatRunRetry(actionId);
- failedRetryTasks.set(actionId, task);
- void task.then(
- () => failedRetryTasks.delete(actionId),
- () => failedRetryTasks.delete(actionId),
- );
- return task;
- }
-
- async function processFailedChatRunRetries(limit = 20) {
- if (shuttingDown) return 0;
- const now = new Date();
- const candidates = await db
- .select({ id: chatActions.id })
- .from(chatActions)
- .where(
- and(
- eq(chatActions.kind, "failed_run_retry"),
- or(
- eq(chatActions.status, "issued"),
- and(
- eq(chatActions.status, "processing"),
- lte(
- chatActions.updatedAt,
- new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS),
- ),
- ),
- ),
- sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${now.toISOString()}::timestamptz)`,
- ),
- )
- .orderBy(asc(chatActions.createdAt), asc(chatActions.id))
- .limit(Math.max(1, Math.min(50, limit)));
- for (const candidate of candidates)
- await processFailedChatRunRetry(candidate.id);
- return candidates.length;
- }
-
- let failedRetryNoticeCursor: { createdAt: Date; id: string } | null = null;
- async function enqueueFailedChatRetryPublications(
- limit: number,
- ): Promise {
- const cursor = failedRetryNoticeCursor;
- const batchSize = Math.max(1, Math.min(50, limit));
- const candidates = await db
- .select({ action: chatActions, receipt: agentWakeupRequests })
- .from(chatActions)
- .innerJoin(
- agentWakeupRequests,
- and(
- eq(agentWakeupRequests.id, chatActions.id),
- eq(agentWakeupRequests.companyId, chatActions.companyId),
- ),
- )
- .where(
- and(
- eq(chatActions.kind, "failed_run_retry"),
- isNull(agentWakeupRequests.runId),
- cursor
- ? sql`(${chatActions.createdAt}, ${chatActions.id}) > (${cursor.createdAt.toISOString()}::timestamptz, ${cursor.id}::uuid)`
- : undefined,
- inArray(agentWakeupRequests.status, [
- "deferred_issue_execution",
- "cancelled",
- "failed",
- "skipped",
- ]),
- sql`not exists (select 1 from chat_publications retry_notice where retry_notice.company_id = ${chatActions.companyId}
- and retry_notice.idempotency_key = 'wake:' || ${chatActions.id}::text || ':' ||
- case when ${agentWakeupRequests.status} = 'deferred_issue_execution' then 'queued' else 'not_started' end || ':' ||
- ${chatActions.endpointId}::text || ':' || ${chatActions.conversationId}::text)`,
- ),
- )
- .orderBy(asc(chatActions.createdAt), asc(chatActions.id))
- .limit(batchSize);
- const last = candidates.at(-1)?.action;
- failedRetryNoticeCursor =
- candidates.length >= batchSize && last
- ? { createdAt: last.createdAt, id: last.id }
- : null;
- let inserted = 0;
- for (const { action, receipt } of candidates) {
- try {
- inserted += await db.transaction(async (tx) => {
- const source = await authorizeFailedChatRunRetry(tx, {
- companyId: action.companyId,
- issueId: String(action.payload.issueId),
- agentId: String(action.payload.agentId),
- retryActionId: action.id,
- publication: true,
- });
- const [current] = await tx
- .select()
- .from(agentWakeupRequests)
- .where(eq(agentWakeupRequests.id, receipt.id))
- .for("update", { noWait: true });
- if (!current || current.runId || current.status !== receipt.status)
- return 0;
- const state =
- current.status === "deferred_issue_execution"
- ? "queued"
- : "not_started";
- await notifyChatPublicationWork(tx);
- return (
- await tx
- .insert(chatPublications)
- .values({
- companyId: action.companyId,
- endpointId: source.endpointId,
- conversationId: source.conversationId,
- issueId: source.issueId,
- commentId: source.commentIds.at(-1)!,
- idempotencyKey: inboundWakePublicationKey(
- action.id,
- state,
- source.endpointId,
- source.conversationId,
- ),
- payload: projectSafeChatPublication({
- classification: "external",
- source: "safe_milestone",
- text:
- state === "queued"
- ? "Your retry is queued."
- : "This retry was not started. Open the task in Paperclip for details.",
- progressState: state === "queued" ? "queued" : "failed",
- }),
- state: "pending",
- })
- .onConflictDoNothing()
- .returning({ id: chatPublications.id })
- ).length;
- });
- } catch (error) {
- if (
- isExternalChatWaitAuthorizationContention(error) ||
- error instanceof NativeChatReviewPresentationContentionError ||
- isExternalActionAuthorizationChange(error) ||
- (error &&
- typeof error === "object" &&
- "status" in error &&
- error.status === 409)
- )
- continue;
- throw error;
- }
- }
- return inserted;
- }
-
+ const {
+ retryCommentIds,
+ assertFailedRetryReceipt,
+ FailedChatRetryPublicationReadError,
+ failedChatRetrySource,
+ unregisterCommittedResponseAuthority,
+ prepareFailedChatRunRetry,
+ authorizeFailedChatRunRetry,
+ unregisterFailedRetryAuthority,
+ processFailedChatRunRetry,
+ processFailedChatRunRetries,
+ enqueueFailedChatRetryPublications,
+ pendingFailedRetries,
+ } = createChatFailedRunRetry(db, {
+ authorizeInboundWakeup,
+ isExternalActionAuthorizationChange,
+ inboundWakeScope,
+ lifecycleRuntimeFence,
+ isShuttingDown: () => shuttingDown,
+ wakeup: (...args) => options.heartbeat.wakeup(...args),
+ staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
+ });
async function authorizeRetainedChatSourcePublication(
tx: DbOrTransaction,
publication: typeof chatPublications.$inferSelect,
@@ -36349,7 +34562,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
getIssueBinding,
shutdown: async () => {
shuttingDown = true;
- await Promise.allSettled([...failedRetryTasks.values()]);
+ await Promise.allSettled(pendingFailedRetries());
unregisterFailedRetryAuthority();
unregisterSlackTaskAuthority();
voice.unregisterAgentTools();
diff --git a/server/src/services/chat-channels/failed-run-retry.ts b/server/src/services/chat-channels/failed-run-retry.ts
new file mode 100644
index 00000000000..c0b8d43988f
--- /dev/null
+++ b/server/src/services/chat-channels/failed-run-retry.ts
@@ -0,0 +1,1825 @@
+import { continuingTeamsFilePublication, type ContinuingTeamsFileTransfer } from "./microsoft-teams/file-transfer-source.js";
+import { notifyChatPublicationWork } from "../chat-work-notifications.js";
+
+import {
+ createHash,
+} from "node:crypto";
+import {
+ and,
+ asc,
+ eq,
+ gte,
+ inArray,
+ isNotNull,
+ isNull,
+ like,
+ lte,
+ ne,
+ notInArray,
+ or,
+ sql,
+} from "drizzle-orm";
+
+import type { Db } from "@paperclipai/db";
+import {
+ createDurableChatWakeupRequest,
+ assertDurableChatWakeupReceipt,
+ registerFailedChatRunRetryAuthority,
+ registerCommittedChatResponseAuthority,
+ CommittedChatResponseAuthorizationError,
+} from "../durable-chat-wakeup.js";
+import {
+ agentWakeupRequests,
+ agents,
+ chatActions,
+ chatConversations,
+ chatDeliveries,
+ chatEndpoints,
+ chatPublications,
+ heartbeatRunEvents,
+ heartbeatRuns,
+ nativeRunFinalizations,
+ nativeRunResults,
+ environmentLeases,
+ issueComments,
+ issueThreadInteractions,
+ issueQuestionResponseDeliveries,
+ issues,
+} from "@paperclipai/db";
+import type {
+ ChatProvider,
+} from "@paperclipai/shared";
+import {
+ isAgentStatusInvokable,
+ CHAT_PROVIDERS,
+ isUuidLike,
+} from "@paperclipai/shared";
+
+import {
+ nativeFailedRunRetryStateIsSafe,
+ nativePreProviderRetryAfterCleanupStateIsSafe,
+ nativeProviderRecoveryEvidence,
+} from "../native-runtime/native-session-executor.js";
+
+import {
+ HttpError,
+ conflict,
+ notFound,
+} from "../../errors.js";
+
+import {
+ logActivity,
+} from "../activity-log.js";
+import {
+ type IssueAssignmentWakeupDeps,
+} from "../issue-assignment-wakeup.js";
+
+import {
+ NativeChatReviewPresentationContentionError,
+} from "../native-runtime/native-chat-review-presentation.js";
+import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
+import { projectSafeChatPublication } from "../chat-publication-projection.js";
+
+import {
+ inboundWakePublicationKey,
+} from "../chat-inbound-wakeup-publications.js";
+
+type DbOrTransaction = Db | Parameters[0]>[0];
+type DeliveryRow = typeof chatDeliveries.$inferSelect;
+type RetryRuntimeFence = { credentialFingerprint: string; generation: number };
+export type ChatFailedRetrySource = Awaited["failedChatRetrySource"]>>;
+
+type ChatFailedRunRetryOptions = {
+ authorizeInboundWakeup: (
+ database: DbOrTransaction,
+ action: typeof chatActions.$inferSelect,
+ notice?: { nonblocking: true; terminal: boolean },
+ ) => Promise<{
+ conversation: Pick;
+ endpoint: Pick;
+ delivery: DeliveryRow;
+ }>;
+ isExternalActionAuthorizationChange: (error: unknown) => boolean;
+ inboundWakeScope: (action: typeof chatActions.$inferSelect) => string;
+ lifecycleRuntimeFence: (delivery: DeliveryRow) => RetryRuntimeFence | null;
+ isShuttingDown: () => boolean;
+ wakeup: IssueAssignmentWakeupDeps["wakeup"];
+ staleProcessingMs: number;
+};
+
+export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOptions) {
+ const {
+ authorizeInboundWakeup,
+ isExternalActionAuthorizationChange,
+ inboundWakeScope,
+ lifecycleRuntimeFence,
+ isShuttingDown,
+ wakeup,
+ staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
+ } = options;
+
+
+ const failedChatRetryDenied = () =>
+ conflict(
+ "The exact failed chat request can no longer be retried safely. Check its current connection, access, and recovery state.",
+ { code: "chat_failed_run_retry_not_authorized" },
+ );
+ class FailedChatRetryPublicationReadError extends Error {
+ constructor() {
+ super("Chat retry publication authorization is temporarily unavailable");
+ }
+ }
+
+ function retryCommentIds(context: Record): string[] {
+ const ids =
+ context.wakeCommentIds ??
+ (context.wakeCommentId ? [context.wakeCommentId] : []);
+ if (
+ !Array.isArray(ids) ||
+ ids.length === 0 ||
+ ids.length > 50 ||
+ ids.some((id) => typeof id !== "string" || !isUuidLike(id)) ||
+ new Set(ids).size !== ids.length ||
+ (context.wakeCommentId !== undefined &&
+ context.wakeCommentId !== ids.at(-1))
+ )
+ throw failedChatRetryDenied();
+ return ids as string[];
+ }
+
+ type FailedChatRetrySource = {
+ failedRunId: string;
+ sourceWakeupRequestId: string;
+ issueId: string;
+ agentId: string;
+ taskKey: string;
+ provider: ChatProvider;
+ commentIds: string[];
+ sources: Array>;
+ endpointId: string;
+ conversationId: string;
+ principalId: string;
+ sessionGeneration: number;
+ requestedByActorType: "user" | "system";
+ requestedByActorId: string;
+ retryAncestors: Array<{
+ actionId: string;
+ failedRunId: string;
+ sourceScopeSha256: string;
+ }>;
+ sourceScopeSha256: string;
+ };
+ function hashFailedRetrySource(
+ scope: Omit,
+ ): FailedChatRetrySource {
+ return {
+ ...scope,
+ sourceScopeSha256: createHash("sha256")
+ .update(JSON.stringify(scope))
+ .digest("hex"),
+ };
+ }
+
+ function assertFailedRetryReceipt(
+ ...args: Parameters
+ ) {
+ try {
+ assertDurableChatWakeupReceipt(...args);
+ } catch {
+ throw failedChatRetryDenied();
+ }
+ }
+
+ /** The reviewed-chat ownership check runs before runtime/profile resolution
+ * or provider startup. Recognize only that exact persisted failure together
+ * with its sole system diagnostic and absence of execution evidence. A
+ * generic setup error (or a prior-session display hint) is not this proof. */
+ async function isPreProviderReviewedChatFailure(
+ tx: DbOrTransaction,
+ run: typeof heartbeatRuns.$inferSelect,
+ ): Promise {
+ const diagnostic = "reviewed_chat_execution_binding_not_authorized";
+ if (
+ run.runtimeMode !== "legacy" ||
+ run.errorCode !== "setup_failed" ||
+ run.error !== diagnostic ||
+ [
+ run.runtimeModeResolverVersion,
+ run.runtimeModeReason,
+ run.runtimeModeResolvedAt,
+ run.runnerProfileJson,
+ run.runnerInstanceId,
+ run.nativeSessionId,
+ run.nativeIssueId,
+ run.nativePhase,
+ run.driverKind,
+ run.driverVersion,
+ run.completionContractId,
+ run.completionContractSha256,
+ run.sessionIdAfter,
+ run.externalRunId,
+ run.processPid,
+ run.processGroupId,
+ run.processStartedAt,
+ run.logStore,
+ run.logRef,
+ run.logBytes,
+ run.logSha256,
+ run.stdoutExcerpt,
+ run.stderrExcerpt,
+ run.lastOutputAt,
+ run.lastOutputStream,
+ run.lastOutputBytes,
+ run.usageJson,
+ run.exitCode,
+ run.signal,
+ ].some((value) => value !== null) ||
+ run.lastOutputSeq !== 0 ||
+ run.logCompressed
+ )
+ return false;
+ const events = await tx
+ .select()
+ .from(heartbeatRunEvents)
+ .where(
+ and(
+ eq(heartbeatRunEvents.companyId, run.companyId),
+ eq(heartbeatRunEvents.runId, run.id),
+ ),
+ )
+ .limit(2)
+ .for("share", { noWait: true });
+ const event = events[0];
+ if (
+ events.length !== 1 ||
+ !event ||
+ event.agentId !== run.agentId ||
+ event.seq !== 1 ||
+ event.eventType !== "error" ||
+ event.stream !== "system" ||
+ event.level !== "error" ||
+ event.message !== diagnostic ||
+ event.payload !== null ||
+ event.sourceInstanceId !== null ||
+ event.sourceEventId !== null ||
+ event.sourceSeq !== null ||
+ event.sourcePayloadSha256 !== null ||
+ event.protocolSchemaVersion !== null
+ )
+ return false;
+ const evidence = await tx.execute(sql`select 1 where
+ exists (select 1 from native_run_finalizations where company_id = ${run.companyId}::uuid and run_id = ${run.id}::uuid)
+ or exists (select 1 from native_run_results where company_id = ${run.companyId}::uuid and run_id = ${run.id}::uuid)
+ or exists (select 1 from environment_leases where company_id = ${run.companyId}::uuid and heartbeat_run_id = ${run.id}::uuid)`);
+ return evidence.length === 0;
+ }
+
+ async function failedNativeRetryCoordinator(
+ tx: DbOrTransaction,
+ run: typeof heartbeatRuns.$inferSelect,
+ issueId: string,
+ ) {
+ const [coordinator] = await tx
+ .select()
+ .from(nativeRunFinalizations)
+ .where(
+ and(
+ eq(nativeRunFinalizations.runId, run.id),
+ eq(nativeRunFinalizations.companyId, run.companyId),
+ eq(nativeRunFinalizations.issueId, issueId),
+ ),
+ )
+ .for("share", { noWait: true });
+ // A queued request can fail before its first provider event because the
+ // predecessor's exact retained checkpoint has not yet been suspended.
+ // A later authenticated cleanup receipt can authorize retrying that
+ // request; never pretend it exhausted provider execution attempts.
+ if (
+ coordinator &&
+ run.nativeIssueId === issueId &&
+ run.status === "failed" &&
+ run.errorCode === "adapter_failed" &&
+ // A diagnostic reason does not change this failure category. The exact
+ // checkpoint, cleanup receipt, and no-provider-work proofs below still
+ // decide whether the original request can be retried.
+ (run.error === "runner_state_identity_mismatch" ||
+ run.error?.startsWith("runner_state_identity_mismatch: ")) &&
+ run.nativePhase === "observed" &&
+ coordinator.phase === "observed" &&
+ coordinator.attempt === 0 &&
+ !coordinator.leaseOwner &&
+ !coordinator.leaseExpiresAt &&
+ !coordinator.nextAttemptAt &&
+ !coordinator.resultId &&
+ !coordinator.assessmentId &&
+ !coordinator.decisionId &&
+ run.nativeSessionId &&
+ run.processPid &&
+ run.processGroupId === run.processPid
+ ) {
+ const [event] = await tx
+ .select({ id: heartbeatRunEvents.id })
+ .from(heartbeatRunEvents)
+ .where(
+ and(
+ eq(heartbeatRunEvents.companyId, run.companyId),
+ eq(heartbeatRunEvents.runId, run.id),
+ or(
+ isNotNull(heartbeatRunEvents.sourceInstanceId),
+ sql`${heartbeatRunEvents.payload}->'prpEvent' is not null`,
+ ),
+ ),
+ )
+ .limit(1);
+ const [result] = await tx
+ .select({ id: nativeRunResults.id })
+ .from(nativeRunResults)
+ .where(
+ and(
+ eq(nativeRunResults.companyId, run.companyId),
+ eq(nativeRunResults.runId, run.id),
+ ),
+ )
+ .limit(1);
+ const checkpoint = run.runnerProfileJson?.sessionCheckpoint as
+ | Record
+ | undefined;
+ const binding = checkpoint?.identity as
+ | Record
+ | undefined;
+ if (
+ !event &&
+ !result &&
+ binding?.companyId === run.companyId &&
+ binding?.issueId === issueId &&
+ binding?.agentId === run.agentId &&
+ binding?.runId === run.id &&
+ binding?.sessionId === run.nativeSessionId &&
+ typeof checkpoint?.sessionId === "string" &&
+ checkpoint.sessionId.trim().length > 0 &&
+ (checkpoint.providerSessionId == null ||
+ (typeof checkpoint.providerSessionId === "string" &&
+ checkpoint.providerSessionId.trim().length > 0))
+ ) {
+ const predecessors = await tx
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.companyId, run.companyId),
+ eq(heartbeatRuns.agentId, run.agentId),
+ eq(heartbeatRuns.nativeIssueId, issueId),
+ eq(heartbeatRuns.nativeSessionId, run.nativeSessionId),
+ eq(heartbeatRuns.processPid, run.processPid),
+ eq(heartbeatRuns.processGroupId, run.processGroupId),
+ eq(heartbeatRuns.runtimeMode, "native"),
+ eq(heartbeatRuns.status, "succeeded"),
+ ne(heartbeatRuns.id, run.id),
+ // Warm runs can share a PID and session. Only a durable maintenance
+ // owner is a candidate; the physical check below must still prove
+ // that this exact run owns the canonical suspended checkpoint.
+ sql`exists (select 1 from native_run_finalizations retired
+ where retired.company_id = ${heartbeatRuns.companyId}
+ and retired.run_id = ${heartbeatRuns.id}
+ and retired.phase = 'committed'
+ and exists (select 1 from jsonb_array_elements(retired.recovery_history) receipt
+ where receipt->>'kind' = 'native_cleanup_maintenance'
+ and receipt->>'phase' = 'settled' and receipt->>'version' = '1'))`,
+ ),
+ )
+ .for("share", { noWait: true })
+ .limit(2);
+ if (predecessors.length === 1) {
+ const predecessor = predecessors[0]!;
+ const [retired] = await tx
+ .select()
+ .from(nativeRunFinalizations)
+ .where(
+ and(
+ eq(nativeRunFinalizations.companyId, run.companyId),
+ eq(nativeRunFinalizations.issueId, issueId),
+ eq(nativeRunFinalizations.runId, predecessor.id),
+ ),
+ )
+ .for("share", { noWait: true });
+ const receipt = retired?.recoveryHistory.findLast(
+ (entry) => entry.kind === "native_cleanup_maintenance",
+ );
+ const leases = await tx
+ .select()
+ .from(environmentLeases)
+ .where(
+ and(
+ eq(environmentLeases.companyId, run.companyId),
+ eq(environmentLeases.heartbeatRunId, run.id),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ retired?.phase === "committed" &&
+ retired.resultId &&
+ retired.assessmentId &&
+ retired.decisionId &&
+ !retired.leaseOwner &&
+ !retired.leaseExpiresAt &&
+ !retired.nextAttemptAt &&
+ receipt?.version === 1 &&
+ receipt.phase === "settled" &&
+ typeof receipt.requestId === "string" &&
+ receipt.requestId.length > 0 &&
+ receipt.nativeSessionId === run.nativeSessionId &&
+ receipt.runnerInstanceId === predecessor.runnerInstanceId &&
+ // The cleanup receipt identifies the provider thread. The
+ // checkpoint's providerSessionId is the separate backend account.
+ receipt.providerSessionId === checkpoint.sessionId &&
+ typeof receipt.settledFingerprint === "string" &&
+ /^[a-f0-9]{64}$/.test(receipt.settledFingerprint) &&
+ typeof receipt.sourceFingerprint === "string" &&
+ /^[a-f0-9]{64}$/.test(receipt.sourceFingerprint) &&
+ leases.every(
+ (lease) =>
+ lease.provider === "local" &&
+ lease.providerLeaseId === null &&
+ lease.issueId === issueId &&
+ ["failed", "released", "expired"].includes(lease.status) &&
+ (lease.cleanupStatus === null ||
+ lease.cleanupStatus === "success") &&
+ lease.releasedAt !== null,
+ )
+ ) {
+ return {
+ coordinator,
+ leases,
+ retiredOwner: {
+ predecessor,
+ receipt,
+ providerSessionId: checkpoint.sessionId,
+ providerBackendSessionId: checkpoint.providerSessionId ?? null,
+ },
+ };
+ }
+ }
+ }
+ }
+ const detail = coordinator?.failureDetail;
+ const ordinary = [
+ "provider_process_exited",
+ "provider_stdout_closed",
+ "provider_process_output_closed",
+ "provider_process_status_failed",
+ "provider_initialize_timeout",
+ "provider_initialize_protocol_error",
+ "provider_request_timeout",
+ "provider_request_protocol_error",
+ "provider_transport_failed",
+ "native_runner_process_exited",
+ ];
+ if (
+ !coordinator ||
+ run.nativeIssueId !== issueId ||
+ run.nativePhase !== "terminal_failure" ||
+ coordinator.phase !== "terminal_failure" ||
+ coordinator.recoveryState !== "blocked" ||
+ coordinator.failureCode !== "native_session_retry_exhausted" ||
+ coordinator.attempt < 3 ||
+ coordinator.leaseOwner ||
+ coordinator.leaseExpiresAt ||
+ coordinator.nextAttemptAt ||
+ coordinator.resultId ||
+ coordinator.assessmentId ||
+ coordinator.decisionId ||
+ !detail ||
+ !ordinary.includes(String(detail.originalFailureCode)) ||
+ !["bootstrap_retry", "exact_checkpoint_resume"].includes(
+ String(detail.recoveryMode),
+ ) ||
+ ![
+ "adapter_failed",
+ "native_session_retry_exhausted",
+ String(detail.originalFailureCode),
+ ].includes(run.errorCode ?? "") ||
+ (detail.recoveryMode === "bootstrap_retry" &&
+ (detail.providerSessionEstablished !== false ||
+ detail.providerEventsExist !== false ||
+ detail.checkpointExists !== false)) ||
+ (detail.recoveryMode === "exact_checkpoint_resume" &&
+ (detail.providerSessionEstablished !== true ||
+ detail.checkpointExists !== true))
+ )
+ throw failedChatRetryDenied();
+ const evidence = await nativeProviderRecoveryEvidence({
+ db: tx as unknown as Db,
+ runId: run.id,
+ sourceFailureCode: detail.originalFailureCode as Parameters<
+ typeof nativeProviderRecoveryEvidence
+ >[0]["sourceFailureCode"],
+ });
+ if (
+ evidence.recoveryMode !== detail.recoveryMode ||
+ evidence.providerSessionEstablished !==
+ detail.providerSessionEstablished ||
+ evidence.providerEventsExist !== detail.providerEventsExist ||
+ evidence.checkpointExists !== detail.checkpointExists
+ )
+ throw failedChatRetryDenied();
+ const leases = await tx
+ .select()
+ .from(environmentLeases)
+ .where(
+ and(
+ eq(environmentLeases.companyId, run.companyId),
+ eq(environmentLeases.heartbeatRunId, run.id),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ leases.some(
+ (lease) =>
+ !["released", "expired", "failed"].includes(lease.status) ||
+ lease.cleanupStatus !== "success" ||
+ !lease.releasedAt,
+ )
+ )
+ throw failedChatRetryDenied();
+ return { coordinator, leases, retiredOwner: null };
+ }
+
+ async function assertFailedNativeRetryState(
+ tx: DbOrTransaction,
+ source: Pick,
+ companyId: string,
+ ) {
+ const [run] = await tx
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, source.failedRunId),
+ eq(heartbeatRuns.companyId, companyId),
+ eq(heartbeatRuns.agentId, source.agentId),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (!run) throw failedChatRetryDenied();
+ if (run.runtimeMode !== "native") return;
+ const { coordinator, leases, retiredOwner } =
+ await failedNativeRetryCoordinator(tx, run, source.issueId);
+ if (retiredOwner) {
+ if (
+ !nativePreProviderRetryAfterCleanupStateIsSafe({
+ failedExecution: run.runnerProfileJson?.nativeExecutionInput,
+ retiredExecution:
+ retiredOwner.predecessor.runnerProfileJson?.nativeExecutionInput,
+ companyId,
+ issueId: source.issueId,
+ agentId: source.agentId,
+ failedRunId: run.id,
+ retiredRunId: retiredOwner.predecessor.id,
+ nativeSessionId: run.nativeSessionId!,
+ runnerInstanceId: retiredOwner.predecessor.runnerInstanceId!,
+ providerSessionId: retiredOwner.providerSessionId,
+ providerBackendSessionId: retiredOwner.providerBackendSessionId,
+ processPid: run.processPid!,
+ processGroupId: run.processGroupId!,
+ receipt: retiredOwner.receipt,
+ })
+ )
+ throw failedChatRetryDenied();
+ return;
+ }
+ const checkpoint = run.runnerProfileJson?.sessionCheckpoint as
+ | Record
+ | undefined;
+ if (
+ !run.nativeSessionId ||
+ !run.runnerInstanceId ||
+ (checkpoint?.providerSessionId != null &&
+ (typeof checkpoint.providerSessionId !== "string" ||
+ checkpoint.providerSessionId.trim().length === 0)) ||
+ !nativeFailedRunRetryStateIsSafe({
+ execution: run.runnerProfileJson?.nativeExecutionInput,
+ companyId,
+ issueId: source.issueId,
+ agentId: source.agentId,
+ runId: run.id,
+ nativeSessionId: run.nativeSessionId,
+ runnerInstanceId: run.runnerInstanceId,
+ processPid: run.processPid,
+ processGroupId: run.processGroupId,
+ providerSessionId:
+ typeof checkpoint?.sessionId === "string"
+ ? checkpoint.sessionId
+ : null,
+ providerBackendSessionId:
+ typeof checkpoint?.providerSessionId === "string"
+ ? checkpoint.providerSessionId
+ : null,
+ recoveryMode: coordinator.failureDetail!.recoveryMode as
+ | "bootstrap_retry"
+ | "exact_checkpoint_resume",
+ allowVerifiedBackup: leases.length > 0,
+ })
+ )
+ throw failedChatRetryDenied();
+ }
+
+ /** Resolve only a complete, originally admitted direct-chat batch. A retry
+ * never turns caller JSON, task-description text, or a provider redelivery
+ * into new input authority. Answer continuations require their own proof. */
+ async function failedChatRetrySource(
+ tx: DbOrTransaction,
+ input: {
+ companyId: string;
+ issueId: string;
+ agentId: string;
+ failedRunId: string;
+ retryActionId?: string;
+ runId?: string;
+ publication?: boolean;
+ /** Internal selector for already committed output, never retry admission. */
+ committedResponse?: {
+ resultId: string;
+ canonicalSha256?: string;
+ decisionId?: string;
+ /** Internal source-proof continuation for one locked Teams transfer.
+ * Never permission to repeat its card/PUT or retry a model run. */
+ continuingTeamsFileTransfer?: ContinuingTeamsFileTransfer;
+ };
+ },
+ visited = new Set(),
+ ): Promise {
+ if (visited.has(input.failedRunId) || visited.size >= 16)
+ throw failedChatRetryDenied();
+ const ancestors = new Set([...visited, input.failedRunId]);
+ const [issue] = await tx
+ .select()
+ .from(issues)
+ .where(
+ and(
+ eq(issues.id, input.issueId),
+ eq(issues.companyId, input.companyId),
+ ),
+ )
+ .for(
+ "update",
+ input.publication || input.retryActionId ? { noWait: true } : undefined,
+ );
+ const [actor] = await tx
+ .select({ status: agents.status })
+ .from(agents)
+ .where(
+ and(
+ eq(agents.id, input.agentId),
+ eq(agents.companyId, input.companyId),
+ ),
+ )
+ .for("share", { noWait: true });
+ const [failedRun] = await tx
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, input.failedRunId),
+ eq(heartbeatRuns.companyId, input.companyId),
+ eq(heartbeatRuns.agentId, input.agentId),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ !issue ||
+ issue.assigneeAgentId !== input.agentId ||
+ !actor ||
+ !isAgentStatusInvokable(actor.status) ||
+ !failedRun ||
+ !failedRun.wakeupRequestId ||
+ (!input.committedResponse &&
+ (!["failed", "timed_out"].includes(failedRun.status) ||
+ !failedRun.finishedAt)) ||
+ (!input.committedResponse &&
+ failedRun.runtimeMode !== "native" &&
+ !(
+ [
+ "adapter_failed",
+ "adapter_exit_code",
+ "process_exit",
+ "timeout",
+ ].includes(failedRun.errorCode ?? "") ||
+ (await isPreProviderReviewedChatFailure(tx, failedRun))
+ )) ||
+ (!input.publication &&
+ ["backlog", "done", "cancelled"].includes(issue.status))
+ )
+ throw failedChatRetryDenied();
+ if (failedRun.runtimeMode === "native" && !input.committedResponse)
+ await failedNativeRetryCoordinator(tx, failedRun, issue.id);
+ const context = failedRun.contextSnapshot ?? {};
+ const provider = CHAT_PROVIDERS.find(
+ (value) => context.source === `chat:${value}`,
+ );
+ if (
+ !provider ||
+ context.issueId !== issue.id ||
+ context.interactionId ||
+ (context.taskKey !== issue.identifier && context.taskKey !== issue.id)
+ )
+ throw failedChatRetryDenied();
+ const commentIds = retryCommentIds(context);
+ const [accepted] = await tx
+ .select()
+ .from(nativeRunResults)
+ .where(
+ and(
+ eq(nativeRunResults.companyId, input.companyId),
+ eq(nativeRunResults.runId, failedRun.id),
+ eq(nativeRunResults.schemaStatus, "accepted"),
+ input.committedResponse
+ ? eq(nativeRunResults.id, input.committedResponse.resultId)
+ : undefined,
+ ),
+ )
+ .limit(1)
+ .for("share", { noWait: true });
+ if (input.committedResponse) {
+ const [coordinator] = await tx
+ .select()
+ .from(nativeRunFinalizations)
+ .where(
+ and(
+ eq(nativeRunFinalizations.companyId, input.companyId),
+ eq(nativeRunFinalizations.issueId, issue.id),
+ eq(nativeRunFinalizations.runId, failedRun.id),
+ ),
+ )
+ .for("share", { noWait: true });
+ const result = accepted?.resultJson.result as
+ | Record
+ | undefined;
+ const terminal = accepted?.resultJson.terminal as
+ | Record
+ | undefined;
+ const continuation = result?.continuation as
+ | Record
+ | undefined;
+ if (
+ failedRun.runtimeMode !== "native" ||
+ failedRun.nativeIssueId !== issue.id ||
+ !accepted ||
+ accepted.id !== input.committedResponse.resultId ||
+ accepted.issueId !== issue.id ||
+ accepted.completionContractId !== failedRun.completionContractId ||
+ coordinator?.phase !== "committed" ||
+ coordinator.resultId !== accepted.id ||
+ !coordinator.decisionId ||
+ !coordinator.assessmentId ||
+ (input.committedResponse.canonicalSha256 !== undefined &&
+ input.committedResponse.canonicalSha256 !==
+ accepted.canonicalSha256) ||
+ (input.committedResponse.decisionId !== undefined &&
+ input.committedResponse.decisionId !== coordinator.decisionId) ||
+ result?.schema !== "paperclip.run_result.v1" ||
+ result.reportedWorkDisposition !== "yielded" ||
+ !Array.isArray(result.attentionRequests) ||
+ result.attentionRequests.length !== 0 ||
+ continuation?.kind !== "response_wake" ||
+ terminal?.schema !== "paperclip.prp.terminal.v1" ||
+ terminal.turnTerminalState !== "completed" ||
+ terminal.runTerminalState !== "succeeded" ||
+ terminal.reportedWorkDisposition !== "yielded"
+ )
+ throw failedChatRetryDenied();
+ }
+ let continuingUnknownPublicationId: string | undefined;
+ const continuingTransfer =
+ input.committedResponse?.continuingTeamsFileTransfer;
+ if (continuingTransfer !== undefined) {
+ continuingUnknownPublicationId = await continuingTeamsFilePublication(tx, {
+ companyId: input.companyId,
+ agentId: input.agentId,
+ issueId: issue.id,
+ failedRunId: failedRun.id,
+ provider,
+ publication: input.publication,
+ retryActionId: input.retryActionId,
+ runId: input.runId,
+ continuingTransfer,
+ }, failedChatRetryDenied);
+ }
+ const [question] = await tx
+ .select({ id: issueThreadInteractions.id })
+ .from(issueThreadInteractions)
+ .where(
+ and(
+ eq(issueThreadInteractions.companyId, input.companyId),
+ eq(issueThreadInteractions.issueId, issue.id),
+ eq(issueThreadInteractions.sourceRunId, failedRun.id),
+ ),
+ )
+ .limit(1);
+ const [answerDelivery] = await tx
+ .select({ id: issueQuestionResponseDeliveries.id })
+ .from(issueQuestionResponseDeliveries)
+ .where(
+ and(
+ eq(issueQuestionResponseDeliveries.companyId, input.companyId),
+ eq(issueQuestionResponseDeliveries.targetRunId, failedRun.id),
+ ),
+ )
+ .limit(1);
+ const [resultPublication] = await tx
+ .select({ id: chatPublications.id })
+ .from(chatPublications)
+ .where(
+ and(
+ eq(chatPublications.companyId, input.companyId),
+ eq(chatPublications.issueId, issue.id),
+ input.committedResponse
+ ? eq(chatPublications.state, "delivery_unknown")
+ : undefined,
+ continuingUnknownPublicationId
+ ? ne(chatPublications.id, continuingUnknownPublicationId)
+ : undefined,
+ or(
+ and(
+ like(chatPublications.idempotencyKey, `run:${failedRun.id}:%`),
+ eq(chatPublications.state, "delivery_unknown"),
+ ),
+ sql`exists (select 1 from issue_comments result_comment where result_comment.id = ${chatPublications.commentId}
+ and result_comment.company_id = ${input.companyId}::uuid and result_comment.created_by_run_id = ${failedRun.id}::uuid
+ )`,
+ ),
+ ),
+ )
+ .limit(1);
+ if (
+ (!input.committedResponse && accepted) ||
+ resultPublication ||
+ question ||
+ answerDelivery
+ )
+ throw failedChatRetryDenied();
+ if (context.chatFailedRunRetry) {
+ const hint = context.chatFailedRunRetry as Record;
+ const [priorIntent] = await tx
+ .select()
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.id, failedRun.wakeupRequestId),
+ eq(chatActions.companyId, input.companyId),
+ ),
+ )
+ .for("share", { noWait: true });
+ const [priorReceipt] = await tx
+ .select()
+ .from(agentWakeupRequests)
+ .where(
+ and(
+ eq(agentWakeupRequests.id, failedRun.wakeupRequestId),
+ eq(agentWakeupRequests.companyId, input.companyId),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ !priorIntent ||
+ priorIntent.kind !== "failed_run_retry" ||
+ priorIntent.status !== "processed" ||
+ !priorReceipt ||
+ priorReceipt.runId !== failedRun.id ||
+ !["failed", "completed"].includes(priorReceipt.status) ||
+ typeof priorIntent.payload.failedRunId !== "string" ||
+ !isUuidLike(priorIntent.payload.failedRunId) ||
+ hint.version !== 1 ||
+ hint.actionId !== priorIntent.id ||
+ hint.failedRunId !== priorIntent.payload.failedRunId ||
+ failedRun.retryOfRunId !== priorIntent.payload.failedRunId ||
+ context.retryOfRunId !== failedRun.retryOfRunId
+ )
+ throw failedChatRetryDenied();
+ const parent = await failedChatRetrySource(
+ tx,
+ {
+ ...input,
+ failedRunId: priorIntent.payload.failedRunId,
+ committedResponse: undefined,
+ },
+ ancestors,
+ );
+ if (
+ priorIntent.payload.sourceScopeSha256 !== parent.sourceScopeSha256 ||
+ priorIntent.endpointId !== parent.endpointId ||
+ priorIntent.conversationId !== parent.conversationId ||
+ priorIntent.principalId !== parent.principalId ||
+ context.taskKey !== parent.taskKey ||
+ provider !== parent.provider ||
+ JSON.stringify(commentIds) !== JSON.stringify(parent.commentIds)
+ )
+ throw failedChatRetryDenied();
+ assertFailedRetryReceipt(
+ createDurableChatWakeupRequest({
+ id: priorIntent.id,
+ companyId: input.companyId,
+ agentId: input.agentId,
+ issueId: input.issueId,
+ commentId: parent.commentIds.at(-1)!,
+ requestedByActorType: parent.requestedByActorType,
+ requestedByActorId: parent.requestedByActorId,
+ requestedAt: priorIntent.createdAt,
+ authorize: async () => {},
+ }),
+ priorReceipt,
+ );
+ const { sourceScopeSha256, ...parentScope } = parent;
+ return hashFailedRetrySource({
+ ...parentScope,
+ failedRunId: failedRun.id,
+ retryAncestors: [
+ ...parent.retryAncestors,
+ {
+ actionId: priorIntent.id,
+ failedRunId: parent.failedRunId,
+ sourceScopeSha256,
+ },
+ ],
+ });
+ }
+ // Include every receipt that fed this run, including a deferred owner's
+ // coalesced children. A mutable latest-comment hint cannot drop a sibling.
+ const admittedBatch =
+ await tx.execute(sql`select source.id, source.payload->>'commentId' as comment_id
+ from agent_wakeup_requests receipt join chat_actions source on source.id = receipt.id
+ where receipt.company_id = ${input.companyId}::uuid and receipt.agent_id = ${input.agentId}::uuid
+ and (receipt.run_id = ${failedRun.id}::uuid or receipt.id = ${failedRun.wakeupRequestId}::uuid
+ or receipt.payload->>'coalescedIntoWakeupRequestId' = ${failedRun.wakeupRequestId})
+ and source.company_id = ${input.companyId}::uuid and source.kind = 'inbound_wakeup' limit 51`);
+ if (
+ admittedBatch.length !== commentIds.length ||
+ admittedBatch.some(
+ (entry) => !commentIds.includes(String(entry.comment_id)),
+ )
+ )
+ throw failedChatRetryDenied();
+ const sources = await tx
+ .select()
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.companyId, input.companyId),
+ eq(chatActions.kind, "inbound_wakeup"),
+ sql`${chatActions.payload}->>'issueId' = ${issue.id}`,
+ inArray(
+ sql`${chatActions.payload}->>'commentId'`,
+ commentIds,
+ ),
+ ),
+ );
+ if (sources.length !== commentIds.length) throw failedChatRetryDenied();
+ const ordered = commentIds.map(
+ (id) => sources.find((action) => action.payload.commentId === id)!,
+ );
+ const first = ordered[0]!;
+ if (
+ !first ||
+ !first.principalId ||
+ !first.conversationId ||
+ sources.some(
+ (action) =>
+ action.endpointId !== first.endpointId ||
+ action.conversationId !== first.conversationId ||
+ action.principalId !== first.principalId ||
+ action.payload.requestedByActorType !==
+ first.payload.requestedByActorType ||
+ action.payload.requestedByActorId !==
+ first.payload.requestedByActorId,
+ )
+ )
+ throw failedChatRetryDenied();
+ const identity = await tx.execute(
+ sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${input.companyId}:${first.principalId}`}, 0)) as locked`,
+ );
+ if (!identity[0]?.locked)
+ throw new NativeChatReviewPresentationContentionError();
+ const canonicalSources: Array> = [];
+ let destination: Awaited> | null =
+ null;
+ for (const action of ordered) {
+ destination = await authorizeInboundWakeup(tx, action, {
+ nonblocking: true,
+ terminal: input.publication === true,
+ }).catch((error) => {
+ if (isExternalActionAuthorizationChange(error))
+ throw failedChatRetryDenied();
+ throw error;
+ });
+ const [delivery] = await tx
+ .select()
+ .from(chatDeliveries)
+ .where(eq(chatDeliveries.id, action.deliveryId!))
+ .for("update", { noWait: true });
+ const [current] = await tx
+ .select()
+ .from(chatActions)
+ .where(eq(chatActions.id, action.id))
+ .for("update", { noWait: true });
+ const [comment] = await tx
+ .select()
+ .from(issueComments)
+ .where(
+ and(
+ eq(issueComments.id, String(action.payload.commentId)),
+ eq(issueComments.companyId, input.companyId),
+ eq(issueComments.issueId, issue.id),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ !current ||
+ current.status !== "processed" ||
+ inboundWakeScope(current) !== inboundWakeScope(action) ||
+ !delivery ||
+ delivery.state !== "processed" ||
+ !comment ||
+ comment.deletedAt ||
+ comment.updatedAt.getTime() !== comment.createdAt.getTime() ||
+ comment.updatedAt >
+ (input.committedResponse
+ ? accepted!.createdAt
+ : failedRun.finishedAt!) ||
+ destination.endpoint.provider !== provider ||
+ JSON.stringify(lifecycleRuntimeFence(delivery)) !==
+ JSON.stringify(lifecycleRuntimeFence(destination.delivery))
+ )
+ throw failedChatRetryDenied();
+ const [lifecycle] = await tx
+ .select({ id: chatDeliveries.id })
+ .from(chatDeliveries)
+ .where(
+ and(
+ eq(chatDeliveries.companyId, input.companyId),
+ eq(chatDeliveries.endpointId, first.endpointId),
+ or(
+ isNull(chatDeliveries.conversationId),
+ eq(chatDeliveries.conversationId, first.conversationId),
+ ),
+ ne(chatDeliveries.state, "filtered"),
+ sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(delivery))}::jsonb`,
+ inArray(chatDeliveries.eventKind, [
+ "message_updated",
+ "message_deleted",
+ "message_restored",
+ ]),
+ sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`,
+ ),
+ )
+ .limit(1);
+ if (lifecycle) throw failedChatRetryDenied();
+ const [receipt] = await tx
+ .select()
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, action.id))
+ .for("share", { noWait: true });
+ if (!receipt) throw failedChatRetryDenied();
+ assertFailedRetryReceipt(
+ createDurableChatWakeupRequest({
+ id: action.id,
+ companyId: input.companyId,
+ agentId: input.agentId,
+ issueId: issue.id,
+ commentId: comment.id,
+ requestedByActorType: action.payload.requestedByActorType as
+ | "user"
+ | "system",
+ requestedByActorId: String(action.payload.requestedByActorId),
+ requestedAt: action.createdAt,
+ authorize: async () => {},
+ }),
+ receipt,
+ );
+ let owner = receipt;
+ const seen = new Set();
+ for (
+ let depth = 0;
+ owner.payload?.coalescedIntoWakeupRequestId !== undefined;
+ depth++
+ ) {
+ const ownerId = owner.payload.coalescedIntoWakeupRequestId;
+ // The scheduler writes a direct child -> canonical owner edge. Nested
+ // chains/cycles are not silently interpreted as additional authority.
+ if (
+ depth >= 1 ||
+ owner.status !== "coalesced" ||
+ typeof ownerId !== "string" ||
+ !isUuidLike(ownerId) ||
+ seen.has(ownerId)
+ )
+ throw failedChatRetryDenied();
+ seen.add(ownerId);
+ const [next] = await tx
+ .select()
+ .from(agentWakeupRequests)
+ .where(
+ and(
+ eq(agentWakeupRequests.id, ownerId),
+ eq(agentWakeupRequests.companyId, input.companyId),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (!next) throw failedChatRetryDenied();
+ owner = next;
+ }
+ if (
+ owner.id !== failedRun.wakeupRequestId ||
+ owner.runId !== failedRun.id ||
+ owner.agentId !== input.agentId ||
+ owner.requestedByActorType !== receipt.requestedByActorType ||
+ owner.requestedByActorId !== receipt.requestedByActorId ||
+ ["skipped", "cancelled"].includes(receipt.status)
+ )
+ throw failedChatRetryDenied();
+ canonicalSources.push({
+ actionId: action.id,
+ deliveryId: delivery.id,
+ commentId: comment.id,
+ principalId: action.principalId,
+ scope: inboundWakeScope(action),
+ fence: lifecycleRuntimeFence(delivery),
+ bodySha256: createHash("sha256").update(comment.body).digest("hex"),
+ omissionReasons: action.payload.attachmentOmissionReasons ?? {},
+ receiptId: receipt.id,
+ ownerId: owner.id,
+ });
+ }
+ if (!destination) throw failedChatRetryDenied();
+ const newestSource = ordered.reduce(
+ (latest, action) =>
+ action.createdAt > latest ? action.createdAt : latest,
+ first.createdAt,
+ );
+ const [newer] = await tx
+ .select({ id: chatActions.id })
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.companyId, input.companyId),
+ eq(chatActions.conversationId, first.conversationId),
+ eq(chatActions.kind, "inbound_wakeup"),
+ notInArray(
+ chatActions.id,
+ ordered.map((action) => action.id),
+ ),
+ gte(chatActions.createdAt, newestSource),
+ sql`exists (select 1 from agent_wakeup_requests newer_admission where newer_admission.id = ${chatActions.id}
+ and newer_admission.company_id = ${input.companyId}::uuid and newer_admission.status <> 'skipped')`,
+ ),
+ )
+ .limit(1);
+ if (newer && !input.publication) throw failedChatRetryDenied();
+ const scope = {
+ failedRunId: failedRun.id,
+ sourceWakeupRequestId: failedRun.wakeupRequestId,
+ issueId: issue.id,
+ agentId: input.agentId,
+ taskKey: context.taskKey as string,
+ provider,
+ commentIds,
+ sources: canonicalSources,
+ endpointId: first.endpointId,
+ conversationId: first.conversationId,
+ principalId: first.principalId,
+ sessionGeneration: destination.conversation.sessionGeneration,
+ requestedByActorType: first.payload.requestedByActorType as
+ | "user"
+ | "system",
+ requestedByActorId: String(first.payload.requestedByActorId),
+ retryAncestors: [],
+ };
+ return hashFailedRetrySource(scope);
+ }
+
+ const unregisterCommittedResponseAuthority =
+ registerCommittedChatResponseAuthority(db, async (tx, input) => {
+ try {
+ await failedChatRetrySource(tx, {
+ ...input,
+ failedRunId: input.runId,
+ publication: true,
+ committedResponse: { resultId: input.resultId },
+ });
+ } catch (error) {
+ if (
+ error instanceof HttpError &&
+ (error.details as { code?: string } | undefined)?.code ===
+ "chat_failed_run_retry_not_authorized"
+ ) {
+ throw new CommittedChatResponseAuthorizationError();
+ }
+ throw error;
+ }
+ });
+
+ async function prepareFailedChatRunRetry(
+ tx: DbOrTransaction,
+ input: {
+ companyId: string;
+ issueId: string;
+ agentId: string;
+ failedRunId: string;
+ initiatedByUserId: string;
+ },
+ ): Promise<{ actionId: string; issueId: string }> {
+ const source = await failedChatRetrySource(tx, input);
+ const providerActionId = `failed-run-retry:${source.failedRunId}`;
+ const [existing] = await tx
+ .select()
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.endpointId, source.endpointId),
+ eq(chatActions.providerActionId, providerActionId),
+ ),
+ )
+ .for("update", { noWait: true });
+ if (existing) {
+ if (
+ existing.kind !== "failed_run_retry" ||
+ existing.companyId !== input.companyId ||
+ existing.payload.sourceScopeSha256 !== source.sourceScopeSha256
+ )
+ throw failedChatRetryDenied();
+ return { actionId: existing.id, issueId: input.issueId };
+ }
+ await assertFailedNativeRetryState(tx, source, input.companyId);
+ const [busy] = await tx
+ .select({ id: heartbeatRuns.id })
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.companyId, input.companyId),
+ inArray(heartbeatRuns.status, [
+ "queued",
+ "running",
+ "scheduled_retry",
+ ]),
+ sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`,
+ ),
+ )
+ .limit(1);
+ if (busy || !input.initiatedByUserId) throw failedChatRetryDenied();
+ const now = new Date();
+ const [intent] = await tx
+ .insert(chatActions)
+ .values({
+ companyId: input.companyId,
+ endpointId: source.endpointId,
+ conversationId: source.conversationId,
+ principalId: source.principalId,
+ kind: "failed_run_retry",
+ providerActionId,
+ payload: {
+ version: 1,
+ ...source,
+ initiatedByUserId: input.initiatedByUserId,
+ },
+ status: "issued",
+ result: { code: "failed_chat_retry_queued", attemptCount: 0 },
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+ await logActivity(tx as unknown as Db, {
+ companyId: input.companyId,
+ actorType: "user",
+ actorId: input.initiatedByUserId,
+ action: "chat.failed_run_retry_requested",
+ entityType: "heartbeat_run",
+ entityId: input.failedRunId,
+ details: {
+ retryActionId: intent.id,
+ issueId: input.issueId,
+ commentCount: source.commentIds.length,
+ },
+ });
+ return { actionId: intent.id, issueId: input.issueId };
+ }
+
+ async function authorizeFailedChatRunRetry(
+ tx: DbOrTransaction,
+ input: {
+ companyId: string;
+ issueId: string;
+ agentId: string;
+ retryActionId: string;
+ runId?: string;
+ publication?: boolean;
+ },
+ ) {
+ const [snapshot] = await tx
+ .select()
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.id, input.retryActionId),
+ eq(chatActions.companyId, input.companyId),
+ ),
+ );
+ if (
+ !snapshot ||
+ snapshot.kind !== "failed_run_retry" ||
+ typeof snapshot.payload.failedRunId !== "string" ||
+ snapshot.payload.issueId !== input.issueId ||
+ snapshot.payload.agentId !== input.agentId
+ )
+ throw failedChatRetryDenied();
+ const source = await failedChatRetrySource(tx, {
+ ...input,
+ failedRunId: snapshot.payload.failedRunId,
+ });
+ const [intent] = await tx
+ .select()
+ .from(chatActions)
+ .where(eq(chatActions.id, snapshot.id))
+ .for("update", { noWait: true });
+ if (
+ !intent ||
+ !(
+ input.publication
+ ? ["issued", "processing", "processed", "failed", "cancelled"]
+ : ["issued", "processing", "processed"]
+ ).includes(intent.status) ||
+ intent.payload.version !== 1 ||
+ intent.endpointId !== source.endpointId ||
+ intent.conversationId !== source.conversationId ||
+ intent.principalId !== source.principalId ||
+ intent.payload.sourceScopeSha256 !== source.sourceScopeSha256 ||
+ JSON.stringify(intent.payload) !== JSON.stringify(snapshot.payload)
+ )
+ throw failedChatRetryDenied();
+ const [receipt] = await tx
+ .select()
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, intent.id))
+ .for("share", { noWait: true });
+ if (receipt) {
+ assertFailedRetryReceipt(
+ createDurableChatWakeupRequest({
+ id: intent.id,
+ companyId: input.companyId,
+ agentId: input.agentId,
+ issueId: input.issueId,
+ commentId: source.commentIds.at(-1)!,
+ requestedByActorType: source.requestedByActorType,
+ requestedByActorId: source.requestedByActorId,
+ requestedAt: intent.createdAt,
+ authorize: async () => {},
+ }),
+ receipt,
+ );
+ if (
+ (!input.publication &&
+ ["skipped", "cancelled"].includes(receipt.status)) ||
+ (input.runId && receipt.runId !== input.runId)
+ )
+ throw failedChatRetryDenied();
+ } else if (input.runId) throw failedChatRetryDenied();
+ if (input.runId) {
+ const [run] = await tx
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, input.runId),
+ eq(heartbeatRuns.companyId, input.companyId),
+ eq(heartbeatRuns.agentId, input.agentId),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ !run ||
+ run.wakeupRequestId !== intent.id ||
+ run.retryOfRunId !== source.failedRunId
+ )
+ throw failedChatRetryDenied();
+ }
+ return { ...source, intent };
+ }
+
+ const unregisterFailedRetryAuthority = registerFailedChatRunRetryAuthority(
+ db,
+ async (tx, input) => {
+ const source = await authorizeFailedChatRunRetry(tx, {
+ ...input,
+ retryActionId: input.wakeupRequestId,
+ });
+ await assertFailedNativeRetryState(tx, source, input.companyId);
+ const context = input.contextSnapshot;
+ const hint = context.chatFailedRunRetry as
+ | Record
+ | undefined;
+ if (
+ context.issueId !== source.issueId ||
+ context.source !== `chat:${source.provider}` ||
+ context.taskKey !== source.taskKey ||
+ context.retryOfRunId !== source.failedRunId ||
+ JSON.stringify(retryCommentIds(context)) !==
+ JSON.stringify(source.commentIds) ||
+ hint?.version !== 1 ||
+ hint.actionId !== source.intent.id ||
+ hint.failedRunId !== source.failedRunId ||
+ !Array.isArray(context.externalAttachmentOmissions) ||
+ JSON.stringify(
+ context.externalAttachmentOmissions.map((entry) =>
+ entry && typeof entry === "object"
+ ? [entry.commentId, entry.reasons]
+ : null,
+ ),
+ ) !==
+ JSON.stringify(
+ source.sources.map((entry) => [
+ entry.commentId,
+ entry.omissionReasons,
+ ]),
+ )
+ )
+ throw failedChatRetryDenied();
+ },
+ );
+
+ async function failedChatRetryReceipt(
+ action: typeof chatActions.$inferSelect,
+ ) {
+ const [receipt] = await db
+ .select()
+ .from(agentWakeupRequests)
+ .where(
+ and(
+ eq(agentWakeupRequests.id, action.id),
+ eq(agentWakeupRequests.companyId, action.companyId),
+ ),
+ );
+ const [run] = receipt?.runId
+ ? await db
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, receipt.runId),
+ eq(heartbeatRuns.companyId, action.companyId),
+ ),
+ )
+ : [];
+ const status = ["succeeded", "failed", "cancelled"].includes(
+ run?.status ?? "",
+ )
+ ? (run!.status as "succeeded" | "failed" | "cancelled")
+ : run?.status === "timed_out"
+ ? "failed"
+ : run?.status === "running"
+ ? "running"
+ : receipt?.status === "deferred_issue_execution"
+ ? "deferred"
+ : ["failed", "cancelled"].includes(action.status)
+ ? (action.status as "failed" | "cancelled")
+ : ["skipped", "failed", "cancelled"].includes(
+ receipt?.status ?? "",
+ )
+ ? "failed"
+ : "queued";
+ return {
+ actionId: action.id,
+ issueId: String(action.payload.issueId),
+ runId: run?.id ?? null,
+ status,
+ };
+ }
+
+ async function claimFailedChatRunRetry(actionId: string) {
+ const [candidate] = await db
+ .select()
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.id, actionId),
+ eq(chatActions.kind, "failed_run_retry"),
+ ),
+ );
+ if (!candidate) throw notFound("Chat retry request not found");
+ const now = new Date();
+ if (
+ isShuttingDown() ||
+ !["issued", "processing"].includes(candidate.status) ||
+ (candidate.status === "processing" &&
+ candidate.updatedAt.getTime() >
+ now.getTime() - DELIVERY_PROCESSING_STALE_MS) ||
+ (typeof candidate.result?.retryAt === "string" &&
+ Date.parse(candidate.result.retryAt) > now.getTime())
+ )
+ return failedChatRetryReceipt(candidate);
+ const [claimed] = await db
+ .update(chatActions)
+ .set({ status: "processing", updatedAt: now })
+ .where(
+ and(
+ eq(chatActions.id, candidate.id),
+ eq(chatActions.status, candidate.status),
+ eq(chatActions.updatedAt, candidate.updatedAt),
+ ),
+ )
+ .returning();
+ if (!claimed) return failedChatRetryReceipt(candidate);
+ const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
+ const settle = async (status: string, result: Record) => {
+ const [current] = await db
+ .update(chatActions)
+ .set({ status, result, updatedAt: new Date() })
+ .where(
+ and(
+ eq(chatActions.id, claimed.id),
+ eq(chatActions.status, "processing"),
+ eq(chatActions.updatedAt, now),
+ ),
+ )
+ .returning();
+ return failedChatRetryReceipt(current ?? claimed);
+ };
+ try {
+ const scope = {
+ companyId: claimed.companyId,
+ issueId: String(claimed.payload.issueId),
+ agentId: String(claimed.payload.agentId),
+ retryActionId: claimed.id,
+ };
+ const source = await db.transaction((tx) =>
+ authorizeFailedChatRunRetry(tx, scope),
+ );
+ const existing = await db
+ .select({ id: agentWakeupRequests.id })
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, claimed.id));
+ if (!existing.length) {
+ const context = {
+ issueId: source.issueId,
+ taskKey: source.taskKey,
+ source: `chat:${source.provider}`,
+ wakeCommentId: source.commentIds.at(-1)!,
+ wakeCommentIds: source.commentIds,
+ retryOfRunId: source.failedRunId,
+ chatFailedRunRetry: {
+ version: 1,
+ actionId: claimed.id,
+ failedRunId: source.failedRunId,
+ },
+ externalAttachmentOmissions: source.sources.map((entry) => ({
+ commentId: entry.commentId,
+ reasons: entry.omissionReasons,
+ })),
+ };
+ await wakeup(source.agentId, {
+ source: "on_demand",
+ triggerDetail: "manual",
+ reason: "retry_failed_run",
+ payload: context,
+ contextSnapshot: context,
+ allowRunCoalescing: false,
+ requestedByActorType: source.requestedByActorType,
+ requestedByActorId: source.requestedByActorId,
+ durableChatRequest: createDurableChatWakeupRequest({
+ id: claimed.id,
+ companyId: claimed.companyId,
+ agentId: source.agentId,
+ issueId: source.issueId,
+ commentId: source.commentIds.at(-1)!,
+ requestedByActorType: source.requestedByActorType,
+ requestedByActorId: source.requestedByActorId,
+ requestedAt: claimed.createdAt,
+ failedRunRetry: { failedRunId: source.failedRunId },
+ authorize: async (tx) => {
+ const current = await authorizeFailedChatRunRetry(tx, scope);
+ await assertFailedNativeRetryState(
+ tx,
+ current,
+ claimed.companyId,
+ );
+ },
+ }),
+ });
+ }
+ const [receipt] = await db
+ .select()
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, claimed.id));
+ if (!receipt) throw new Error("chat_retry_receipt_not_committed");
+ return settle(
+ ["skipped", "failed", "cancelled"].includes(receipt.status)
+ ? "failed"
+ : "processed",
+ { code: "failed_chat_retry_durable", attemptCount },
+ );
+ } catch (error) {
+ const [receipt] = await db
+ .select()
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, claimed.id));
+ if (receipt)
+ return settle("processed", {
+ code: "failed_chat_retry_durable",
+ attemptCount,
+ });
+ const denied =
+ error &&
+ typeof error === "object" &&
+ "details" in error &&
+ error.details &&
+ typeof error.details === "object" &&
+ "code" in error.details &&
+ [
+ "chat_failed_run_retry_not_authorized",
+ "chat_action_authorization_changed",
+ ].includes(String(error.details.code));
+ const retryable = !denied && attemptCount < 8;
+ return settle(retryable ? "issued" : "failed", {
+ code: retryable
+ ? "failed_chat_retry_contended"
+ : "failed_chat_retry_not_authorized",
+ attemptCount,
+ ...(retryable
+ ? {
+ retryAt: new Date(
+ Date.now() + Math.min(5_000, attemptCount * 250),
+ ).toISOString(),
+ }
+ : {}),
+ });
+ }
+ }
+
+ const failedRetryTasks = new Map<
+ string,
+ ReturnType
+ >();
+ function processFailedChatRunRetry(actionId: string) {
+ const existing = failedRetryTasks.get(actionId);
+ if (existing) return existing;
+ const task = claimFailedChatRunRetry(actionId);
+ failedRetryTasks.set(actionId, task);
+ void task.then(
+ () => failedRetryTasks.delete(actionId),
+ () => failedRetryTasks.delete(actionId),
+ );
+ return task;
+ }
+
+ async function processFailedChatRunRetries(limit = 20) {
+ if (isShuttingDown()) return 0;
+ const now = new Date();
+ const candidates = await db
+ .select({ id: chatActions.id })
+ .from(chatActions)
+ .where(
+ and(
+ eq(chatActions.kind, "failed_run_retry"),
+ or(
+ eq(chatActions.status, "issued"),
+ and(
+ eq(chatActions.status, "processing"),
+ lte(
+ chatActions.updatedAt,
+ new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS),
+ ),
+ ),
+ ),
+ sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${now.toISOString()}::timestamptz)`,
+ ),
+ )
+ .orderBy(asc(chatActions.createdAt), asc(chatActions.id))
+ .limit(Math.max(1, Math.min(50, limit)));
+ for (const candidate of candidates)
+ await processFailedChatRunRetry(candidate.id);
+ return candidates.length;
+ }
+
+ let failedRetryNoticeCursor: { createdAt: Date; id: string } | null = null;
+ async function enqueueFailedChatRetryPublications(
+ limit: number,
+ ): Promise {
+ const cursor = failedRetryNoticeCursor;
+ const batchSize = Math.max(1, Math.min(50, limit));
+ const candidates = await db
+ .select({ action: chatActions, receipt: agentWakeupRequests })
+ .from(chatActions)
+ .innerJoin(
+ agentWakeupRequests,
+ and(
+ eq(agentWakeupRequests.id, chatActions.id),
+ eq(agentWakeupRequests.companyId, chatActions.companyId),
+ ),
+ )
+ .where(
+ and(
+ eq(chatActions.kind, "failed_run_retry"),
+ isNull(agentWakeupRequests.runId),
+ cursor
+ ? sql`(${chatActions.createdAt}, ${chatActions.id}) > (${cursor.createdAt.toISOString()}::timestamptz, ${cursor.id}::uuid)`
+ : undefined,
+ inArray(agentWakeupRequests.status, [
+ "deferred_issue_execution",
+ "cancelled",
+ "failed",
+ "skipped",
+ ]),
+ sql`not exists (select 1 from chat_publications retry_notice where retry_notice.company_id = ${chatActions.companyId}
+ and retry_notice.idempotency_key = 'wake:' || ${chatActions.id}::text || ':' ||
+ case when ${agentWakeupRequests.status} = 'deferred_issue_execution' then 'queued' else 'not_started' end || ':' ||
+ ${chatActions.endpointId}::text || ':' || ${chatActions.conversationId}::text)`,
+ ),
+ )
+ .orderBy(asc(chatActions.createdAt), asc(chatActions.id))
+ .limit(batchSize);
+ const last = candidates.at(-1)?.action;
+ failedRetryNoticeCursor =
+ candidates.length >= batchSize && last
+ ? { createdAt: last.createdAt, id: last.id }
+ : null;
+ let inserted = 0;
+ for (const { action, receipt } of candidates) {
+ try {
+ inserted += await db.transaction(async (tx) => {
+ const source = await authorizeFailedChatRunRetry(tx, {
+ companyId: action.companyId,
+ issueId: String(action.payload.issueId),
+ agentId: String(action.payload.agentId),
+ retryActionId: action.id,
+ publication: true,
+ });
+ const [current] = await tx
+ .select()
+ .from(agentWakeupRequests)
+ .where(eq(agentWakeupRequests.id, receipt.id))
+ .for("update", { noWait: true });
+ if (!current || current.runId || current.status !== receipt.status)
+ return 0;
+ const state =
+ current.status === "deferred_issue_execution"
+ ? "queued"
+ : "not_started";
+ await notifyChatPublicationWork(tx);
+ return (
+ await tx
+ .insert(chatPublications)
+ .values({
+ companyId: action.companyId,
+ endpointId: source.endpointId,
+ conversationId: source.conversationId,
+ issueId: source.issueId,
+ commentId: source.commentIds.at(-1)!,
+ idempotencyKey: inboundWakePublicationKey(
+ action.id,
+ state,
+ source.endpointId,
+ source.conversationId,
+ ),
+ payload: projectSafeChatPublication({
+ classification: "external",
+ source: "safe_milestone",
+ text:
+ state === "queued"
+ ? "Your retry is queued."
+ : "This retry was not started. Open the task in Paperclip for details.",
+ progressState: state === "queued" ? "queued" : "failed",
+ }),
+ state: "pending",
+ })
+ .onConflictDoNothing()
+ .returning({ id: chatPublications.id })
+ ).length;
+ });
+ } catch (error) {
+ if (
+ isExternalChatWaitAuthorizationContention(error) ||
+ error instanceof NativeChatReviewPresentationContentionError ||
+ isExternalActionAuthorizationChange(error) ||
+ (error &&
+ typeof error === "object" &&
+ "status" in error &&
+ error.status === 409)
+ )
+ continue;
+ throw error;
+ }
+ }
+ return inserted;
+ }
+
+ return {
+ retryCommentIds,
+ assertFailedRetryReceipt,
+ FailedChatRetryPublicationReadError,
+ failedChatRetrySource,
+ unregisterCommittedResponseAuthority,
+ prepareFailedChatRunRetry,
+ authorizeFailedChatRunRetry,
+ unregisterFailedRetryAuthority,
+ processFailedChatRunRetry,
+ processFailedChatRunRetries,
+ enqueueFailedChatRetryPublications,
+ pendingFailedRetries: () => [...failedRetryTasks.values()],
+ };
+}
diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-source.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-source.ts
new file mode 100644
index 00000000000..5990648fa8e
--- /dev/null
+++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-source.ts
@@ -0,0 +1,149 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { assets, chatConversations, chatEndpoints, chatPublications, chatTeamsFileTransfers, issueAttachments, issueComments, type Db } from "@paperclipai/db";
+import { isUuidLike, type ChatProvider } from "@paperclipai/shared";
+
+type DbOrTransaction = Db | Parameters[0]>[0];
+
+export type ContinuingTeamsFileTransfer = {
+ transferId: string;
+ publicationId: string;
+ version: number;
+ phase: "consent_unknown" | "file_info_unknown";
+};
+
+type TeamsFileTransferSourceInput = {
+ companyId: string;
+ agentId: string;
+ issueId: string;
+ failedRunId: string;
+ provider: ChatProvider;
+ publication?: boolean;
+ retryActionId?: string;
+ runId?: string;
+ continuingTransfer: ContinuingTeamsFileTransfer;
+};
+
+// A selector proves only this transfer's source. The caller must still verify
+// the committed result and complete original source batch in this transaction.
+export async function continuingTeamsFilePublication(
+ tx: DbOrTransaction,
+ input: TeamsFileTransferSourceInput,
+ failedChatRetryDenied: () => Error,
+): Promise {
+ const { provider, continuingTransfer } = input;
+ if (
+ input.publication !== true ||
+ input.retryActionId !== undefined ||
+ input.runId !== undefined ||
+ provider !== "microsoft-teams" ||
+ !continuingTransfer ||
+ !isUuidLike(continuingTransfer.transferId) ||
+ !isUuidLike(continuingTransfer.publicationId) ||
+ !Number.isSafeInteger(continuingTransfer.version) ||
+ continuingTransfer.version < 1 ||
+ !["consent_unknown", "file_info_unknown"].includes(
+ continuingTransfer.phase,
+ )
+ )
+ throw failedChatRetryDenied();
+ // The caller supplies a selector only. Independently prove the exact
+ // unknown publication is this native run's own selected file, not a
+ // sibling effect or another actor/generation. All ordinary source and
+ // committed-result authorization below still runs unchanged.
+ const [continuing] = await tx
+ .select({
+ publicationId: chatPublications.id,
+ attemptId: chatTeamsFileTransfers.attemptId,
+ attemptExpiresAt: chatTeamsFileTransfers.attemptExpiresAt,
+ })
+ .from(chatTeamsFileTransfers)
+ .innerJoin(
+ chatPublications,
+ and(
+ eq(chatPublications.id, chatTeamsFileTransfers.publicationId),
+ eq(chatPublications.companyId, chatTeamsFileTransfers.companyId),
+ eq(chatPublications.endpointId, chatTeamsFileTransfers.endpointId),
+ eq(
+ chatPublications.conversationId,
+ chatTeamsFileTransfers.conversationId,
+ ),
+ eq(chatPublications.issueId, chatTeamsFileTransfers.issueId),
+ eq(chatPublications.commentId, chatTeamsFileTransfers.commentId),
+ eq(chatPublications.state, "delivery_unknown"),
+ sql`${chatPublications.payload}->'attachmentIds' = jsonb_build_array(${chatTeamsFileTransfers.attachmentId}::text)`,
+ ),
+ )
+ .innerJoin(
+ chatEndpoints,
+ and(
+ eq(chatEndpoints.id, chatTeamsFileTransfers.endpointId),
+ eq(chatEndpoints.companyId, input.companyId),
+ eq(chatEndpoints.provider, "microsoft-teams"),
+ ),
+ )
+ .innerJoin(
+ chatConversations,
+ and(
+ eq(chatConversations.id, chatTeamsFileTransfers.conversationId),
+ eq(chatConversations.companyId, input.companyId),
+ eq(chatConversations.endpointId, chatTeamsFileTransfers.endpointId),
+ eq(chatConversations.issueId, input.issueId),
+ eq(
+ chatConversations.sessionGeneration,
+ chatTeamsFileTransfers.conversationGeneration,
+ ),
+ ),
+ )
+ .innerJoin(
+ issueComments,
+ and(
+ eq(issueComments.id, chatTeamsFileTransfers.commentId),
+ eq(issueComments.companyId, input.companyId),
+ eq(issueComments.issueId, input.issueId),
+ eq(issueComments.createdByRunId, input.failedRunId),
+ eq(issueComments.authorAgentId, input.agentId),
+ isNull(issueComments.deletedAt),
+ ),
+ )
+ .innerJoin(
+ issueAttachments,
+ and(
+ eq(issueAttachments.id, chatTeamsFileTransfers.attachmentId),
+ eq(issueAttachments.companyId, input.companyId),
+ eq(issueAttachments.issueId, input.issueId),
+ eq(issueAttachments.issueCommentId, issueComments.id),
+ eq(issueAttachments.originatingRunId, input.failedRunId),
+ ),
+ )
+ .innerJoin(
+ assets,
+ and(
+ eq(assets.id, issueAttachments.assetId),
+ eq(assets.companyId, input.companyId),
+ eq(assets.sha256, chatTeamsFileTransfers.sha256),
+ eq(assets.byteSize, chatTeamsFileTransfers.byteSize),
+ ),
+ )
+ .where(
+ and(
+ eq(chatTeamsFileTransfers.companyId, input.companyId),
+ eq(chatTeamsFileTransfers.issueId, input.issueId),
+ eq(chatTeamsFileTransfers.id, continuingTransfer.transferId),
+ eq(
+ chatTeamsFileTransfers.publicationId,
+ continuingTransfer.publicationId,
+ ),
+ eq(chatTeamsFileTransfers.version, continuingTransfer.version),
+ eq(chatTeamsFileTransfers.phase, continuingTransfer.phase),
+ ),
+ )
+ .for("share", { noWait: true });
+ if (
+ !continuing ||
+ (continuing.attemptId &&
+ (!continuing.attemptExpiresAt ||
+ continuing.attemptExpiresAt > new Date()))
+ )
+ throw failedChatRetryDenied();
+ return continuing.publicationId;
+}
From 37913e4907324838ae2c8c94dc8155002b6d4223 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:51:17 -0500
Subject: [PATCH 10/23] refactor(heartbeat): extract run output recording
(#15969)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat runs stream adapter output and persist execution results.
> - Output recording currently sits inside the executor beside admission
and cleanup.
> - Log handles, sequence numbers, progress writes, and excerpt buffers
share one attempt lifetime.
> - This pull request moves that state and its callbacks into a
per-attempt recorder.
> - The executor keeps dispatch and finalization visible, while
finalization reads live output values.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Maintenance of heartbeat execution. This PR follows #15966, which is now
merged. The branch is rebased onto `master`; `git range-diff` confirms
that its extraction patch is unchanged.
**Current behavior**
`heartbeat.ts` has 4,740 lines and 195,258 bytes. It directly owns log
handles, excerpt buffers, output sequence numbers, progress flushes, and
adapter event callbacks.
**Proposed behavior**
`run-output.ts` owns the per-attempt recorder. The main file has 4,561
lines and 188,950 bytes; the recorder has 209 lines and 7,757 bytes.
**Reason and benefit**
Output state has one explicit lifetime and interface. The shared log
store and finalization policy retain their original owners. Related open
output work includes #12539 and #15524; this PR does not change
redaction policy.
**Breaking changes**
None intended. Public flush constants and log formatting exports remain
available from `heartbeat.ts`.
## What Changed
- Move log initialization, redaction, excerpts, progress persistence,
live delivery, and adapter callbacks into `createHeartbeatRunOutput`.
- Preserve the acquired log handle if later setup fails. Bind callbacks
to the execution-start run row.
- Pass live excerpt getters to success and failure completion so output
arriving during finalization remains visible.
- Add three real-database public-path tests. Extend browser acceptance
to inspect persisted worker stdout and output byte counts.
- Update the module map.
## Verification
- Before extraction: 23 tests pass, including all three new output
characterization cases.
- After extraction: 45 tests pass across output, log formatting, event
sequencing, and run completion. No skips.
- `pnpm -r typecheck` and `pnpm build` pass.
- Normalized syntax comparison confirms the moved bodies and
reconstructed executor match. Reviewed the factory ownership, callback
binding, getter wiring, and constant re-exports separately.
- Isolated browser acceptance: 2/2 pass, including persisted worker
stdout and matching final byte counts. The process worker completes
after recovery; persistent timeouts stop after three attempts. Both
terminal states survive reload.
- Hosted full-suite CI passes on
`d32c8fc40e72b9766bdd9e191d777e8dc554e3f2`; all 55 reported checks are
successful. Greptile is 5/5 with zero review threads.
- The initial chat shard timed out waiting for GitHub mention admission.
Its fixture mocks heartbeat wakeup and never calls the output recorder.
Both parameterized cases passed locally; the unchanged shard passed on
one rerun. No test or timeout changes.
- Merge preparation: all seven session-preparation and run-output
regression tests pass after rebasing onto `master`. Fresh full CI passes
for `60e2a49ce2879645a9f618a801e0ba0903ede161`: 55 successful checks and
two optional Storybook skips, including all eight browser shards,
typecheck, tests, and build. Greptile is 5/5 on this exact commit with
zero review threads.
## Risks
- A stale excerpt snapshot could lose output that arrives while
finalization awaits I/O. Passing-before/passing-after tests exercise
this for both successful and failed runs.
- Per-attempt state must not become service-wide. Each executor
constructs its own recorder and keeps the existing shared store.
- Browser acceptance uses a real process fixture and disposable data; it
makes no live provider calls.
## Model Used
OpenAI Codex, based on GPT-6, assisted with reasoning, code edits, tool
execution, and verification. The exact deployment model ID 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
---
doc/DEVELOPING.md | 7 +-
.../__tests__/heartbeat-run-output.test.ts | 121 ++++++++++
server/src/services/heartbeat.ts | 203 +----------------
server/src/services/heartbeat/run-output.ts | 209 ++++++++++++++++++
.../e2e/workspace-bootstrap/recovery.spec.ts | 6 +
5 files changed, 353 insertions(+), 193 deletions(-)
create mode 100644 server/src/__tests__/heartbeat-run-output.test.ts
create mode 100644 server/src/services/heartbeat/run-output.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 7cf3e8b1471..da1bc58a960 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1302,8 +1302,11 @@ acquire no runtime resources. Public-path characterization tests live in
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
-excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
-event delivery. Existing public helpers remain available from `heartbeat.ts`.
+excerpts. `run-output.ts` owns per-attempt log handles, current-user redaction,
+output progress writes, live log delivery, and adapter event callbacks. The log
+store remains service-scoped. Completion reads live excerpt getters so output
+that arrives during finalization is retained. Existing public helpers and flush
+interval constants remain available from `heartbeat.ts`.
Workspace preparation is in `server/src/services/heartbeat/workspaces.ts`. It
owns managed checkout materialization, workspace validation and reuse, referenced
diff --git a/server/src/__tests__/heartbeat-run-output.test.ts b/server/src/__tests__/heartbeat-run-output.test.ts
new file mode 100644
index 00000000000..a912d6e0e37
--- /dev/null
+++ b/server/src/__tests__/heartbeat-run-output.test.ts
@@ -0,0 +1,121 @@
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
+import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
+import { heartbeatService } from "../services/heartbeat.js";
+import { getRunLogStore } from "../services/run-log-store.js";
+import { subscribeCompanyLiveEvents } from "../services/live-events.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+const describePostgres = support.supported ? describe : describe.skip;
+if (!support.supported) console.warn(`Skipping run output tests: ${support.reason}`);
+
+describePostgres("heartbeat output through adapter dispatch and finalization", () => {
+ const adapterType = "run_output_test";
+ let temporary: Awaited>;
+ let db: ReturnType;
+ let heartbeat: ReturnType;
+ const executeByAgent = new Map Promise>();
+
+ beforeAll(async () => {
+ temporary = await startEmbeddedPostgresTestDatabase("heartbeat-run-output-");
+ db = createDb(temporary.connectionString);
+ heartbeat = heartbeatService(db);
+ registerServerAdapter({
+ type: adapterType, supportsLocalAgentJwt: false,
+ execute: async (input) => {
+ await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
+ await executeByAgent.get(input.agent.id)!(input);
+ return { exitCode: 0, signal: null, timedOut: false, resultJson: {} };
+ },
+ testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
+ });
+ }, 30_000);
+ afterEach(() => vi.restoreAllMocks());
+ afterAll(async () => {
+ await heartbeat?.drainActiveRunExecutions();
+ unregisterServerAdapter(adapterType);
+ await db?.$client.end({ timeout: 0 });
+ await temporary?.cleanup();
+ });
+
+ async function seed(execute: (input: AdapterExecutionContext) => Promise) {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Output fixture", issuePrefix: `O${companyId.slice(0, 6)}` });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Output writer", adapterType, status: "idle" });
+ await db.insert(issues).values({ id: issueId, companyId, title: "Output fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: "output-user", issueNumber: 1 });
+ executeByAgent.set(agentId, execute);
+ return { companyId, agentId, issueId };
+ }
+ async function execute(f: Awaited>) {
+ const run = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual", payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId } });
+ expect(run).not.toBeNull();
+ await heartbeat.drainActiveRunExecutions();
+ const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
+ expect(finished).toBeDefined();
+ const log = await heartbeat.readLog(run!.id);
+ const entries = log.content.trim().split("\n").filter(Boolean).map(line => JSON.parse(line));
+ return { finished, entries };
+ }
+
+ it("redacts stored and live chunks, preserves sequence, and flushes the last output at completion", async () => {
+ const secret = "output-test-bearer-canary";
+ const f = await seed(async input => {
+ await input.onLog("stdout", `first: Authorization: Bearer ${secret}\n`);
+ await input.onLog("stdout", `${"x".repeat(10_000)}live-tail\n`);
+ await input.onLog("stderr", "last stderr\n");
+ await input.onMeta?.({ adapterType, command: "fixture", env: { PAPERCLIP_API_KEY: secret }, prompt: "fixture prompt" });
+ await input.onEvent?.({ eventType: " fixture.progress ", stream: "system", level: "info", message: "fixture event", payload: { step: 1 } });
+ await input.onEvent?.({ eventType: " ", stream: "system", level: "info", message: "ignored blank event" });
+ });
+ const live: Record[] = [];
+ const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
+ if (event.type === "heartbeat.run.log") live.push(event.payload);
+ });
+ try {
+ const { finished, entries } = await execute(f);
+ expect(finished.status, finished.error ?? undefined).toBe("succeeded");
+ const ownLive = live.filter(event => event.runId === finished.id);
+ expect(entries.map(entry => entry.seq)).toEqual(entries.map((_, i) => i + 1));
+ expect(ownLive.map(event => event.seq)).toEqual(entries.map(entry => entry.seq));
+ expect(JSON.stringify(entries)).not.toContain(secret);
+ expect(JSON.stringify(ownLive)).not.toContain(secret);
+ expect(finished.stdoutExcerpt).not.toContain(secret);
+ expect(finished.stderrExcerpt).toContain("last stderr");
+ expect(ownLive.find(event => String(event.chunk).endsWith("live-tail\n"))).toMatchObject({ truncated: true });
+ expect(finished.lastOutputSeq).toBe(entries.at(-1).seq);
+ expect(finished.lastOutputStream).toBe("stderr");
+ expect(finished.lastOutputBytes).toBe(finished.logBytes);
+ expect(finished.lastOutputAt).toEqual(new Date(entries.at(-1).ts));
+ const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
+ expect(JSON.stringify(events)).not.toContain(secret);
+ expect(events.find(event => event.eventType === "adapter.invoke")?.payload).toMatchObject({ env: { PAPERCLIP_API_KEY: "***REDACTED***" } });
+ expect(events.find(event => event.eventType === "fixture.progress")).toMatchObject({ message: "fixture event", payload: { step: 1 } });
+ expect(events.some(event => event.message === "ignored blank event")).toBe(false);
+ } finally { unsubscribe(); }
+ });
+
+ it.each([false, true])("retains output arriving during finalization (adapter throws: %s)", async fail => {
+ let onLog: AdapterExecutionContext["onLog"];
+ const f = await seed(async input => {
+ onLog = input.onLog;
+ await onLog("stdout", "before finalization\n");
+ if (fail) throw new Error("invalid fixture configuration");
+ });
+ const store = getRunLogStore();
+ const finalize = store.finalize.bind(store);
+ vi.spyOn(store, "finalize").mockImplementationOnce(async handle => {
+ await onLog("stdout", "during finalization\n");
+ return finalize(handle);
+ });
+ const { finished, entries } = await execute(f);
+ expect(finished.status, finished.error ?? undefined).toBe(fail ? "failed" : "succeeded");
+ expect(finished.stdoutExcerpt).toContain("before finalization");
+ expect(finished.stdoutExcerpt).toContain("during finalization");
+ expect(entries.at(-1).chunk).toBe("during finalization\n");
+ expect(finished.lastOutputSeq).toBe(entries.at(-1).seq);
+ expect(finished.lastOutputBytes).toBe(finished.logBytes);
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 03274998d80..54006b8b7ef 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { createHeartbeatRunOutput } from "./heartbeat/run-output.js";
import { prepareHeartbeatSession, resolveHeartbeatAdapterSession } from "./heartbeat/session-preparation.js";
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js";
@@ -30,8 +31,6 @@ export {
} from "./heartbeat/run-control.js";
import {
isHeartbeatRunTerminalStatus,
- isHeartbeatRunRuntimeStatusActive,
- publishHeartbeatRunRuntimeProgress,
decorateHeartbeatRunRuntimeStatus,
createHeartbeatLifecycle,
} from "./heartbeat/run-lifecycle.js";
@@ -202,11 +201,7 @@ export {
stripPaperclipSessionMetadataFromSessionParams,
resolveTaskSessionConfigFreshness,
} from "./heartbeat/workspaces.js";
-import {
- appendExcerpt,
- boundHeartbeatRunEventPayloadForStorage,
- compactRunLogChunk,
-} from "./heartbeat/run-log.js";
+import { boundHeartbeatRunEventPayloadForStorage } from "./heartbeat/run-log.js";
export {
boundHeartbeatRunEventPayloadForStorage,
compactRunLogChunk,
@@ -375,15 +370,11 @@ import {
type NativeSessionBackend,
} from "../vendor/paperclip-runner/index.js";
-import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
+import { getRunLogStore } from "./run-log-store.js";
import {
providerTraceStore,
} from "./provider-trace-store.js";
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
-import type {
- AdapterInvocationMeta,
- AdapterRuntimeEvent,
-} from "../adapters/index.js";
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
import {
@@ -522,9 +513,6 @@ import {
DIRECT_NON_INVOKABLE_STATUSES,
} from "./agent-invokability.js";
-import {
- redactCurrentUserText,
-} from "../log-redaction.js";
import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
@@ -544,7 +532,6 @@ import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import {
clearHeartbeatRunRuntimeStatus,
sweepExpiredHeartbeatRunRuntimeStatuses,
- touchHeartbeatRunRuntimeStatus,
} from "./heartbeat-run-runtime-status.js";
import {
@@ -557,8 +544,6 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js";
-const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
-
const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
"paperclipai/paperclip/paperclip-converting-plans-to-tasks";
@@ -574,8 +559,7 @@ export {
ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS,
ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS,
} from "./recovery/service.js";
-export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000;
-export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000;
+export { ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS, ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS } from "./heartbeat/run-output.js";
export {
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
@@ -3381,49 +3365,16 @@ export function heartbeatService(
evaluateSessionCompaction,
});
- let handle: RunLogHandle | null = null;
+ const runOutput = createHeartbeatRunOutput(db, {
+ run, runId, issueId, runLogStore, identityRedactor, secretKeys,
+ appendRunEvent, getCurrentUserRedactionOptions,
+ });
const goalCheckpointSession: {
current: {
params: Record;
displayId: string;
} | null;
} = { current: null };
- let stdoutExcerpt = "";
- let stderrExcerpt = "";
- let outputSeq = Number(run.lastOutputSeq ?? 0);
- let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null;
- let lastLogRuntimeStatusTouchMs = 0;
- const outputProgressState: {
- pending: {
- at: Date;
- seq: number;
- stream: "stdout" | "stderr";
- bytes: number;
- } | null;
- } = { pending: null };
- let persistedLogBytes = Number(run.logBytes ?? 0);
- const flushOutputProgress = async (opts?: { force?: boolean }) => {
- const pendingOutputProgress = outputProgressState.pending;
- if (!pendingOutputProgress) return;
- const shouldFlush =
- opts?.force === true ||
- !lastOutputFlushAt ||
- pendingOutputProgress.at.getTime() - lastOutputFlushAt.getTime() >=
- ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS;
- if (!shouldFlush) return;
- await db
- .update(heartbeatRuns)
- .set({
- lastOutputAt: pendingOutputProgress.at,
- lastOutputSeq: pendingOutputProgress.seq,
- lastOutputStream: pendingOutputProgress.stream,
- lastOutputBytes: pendingOutputProgress.bytes,
- updatedAt: new Date(),
- })
- .where(eq(heartbeatRuns.id, run.id));
- lastOutputFlushAt = pendingOutputProgress.at;
- outputProgressState.pending = null;
- };
try {
const startedAt = run.startedAt ?? new Date();
const runningWithSession = await db
@@ -3502,100 +3453,8 @@ export function heartbeatService(
message: "run started",
});
- handle = await runLogStore.begin({
- companyId: run.companyId,
- agentId: run.agentId,
- runId,
- });
-
- await db
- .update(heartbeatRuns)
- .set({
- logStore: handle.store,
- logRef: handle.logRef,
- updatedAt: new Date(),
- })
- .where(eq(heartbeatRuns.id, runId));
-
- const currentUserRedactionOptions =
- await getCurrentUserRedactionOptions();
- const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => {
- const sanitizedChunk = compactRunLogChunk(
- redactCurrentUserText(chunk, currentUserRedactionOptions),
- );
- if (stream === "stdout")
- stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
- if (stream === "stderr")
- stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
- const ts = new Date().toISOString();
-
- outputSeq += 1;
- const chunkSeq = outputSeq;
- let appendedBytes = 0;
- if (handle) {
- appendedBytes = await runLogStore.append(handle, {
- stream,
- chunk: sanitizedChunk,
- ts,
- seq: chunkSeq,
- });
- persistedLogBytes += appendedBytes;
- }
- outputProgressState.pending = {
- at: new Date(ts),
- seq: chunkSeq,
- stream,
- bytes: persistedLogBytes,
- };
- await flushOutputProgress();
-
- // Streamed CLI output is real run activity: keep the in-memory
- // runtime status ("Working... / X ago") fresh between structured
- // events so sandbox runs with mid-run log streaming never show a
- // minutes-stale timestamp. Throttled to avoid churning the live
- // event stream on every 250ms tail chunk.
- const logActivityAt = new Date(ts);
- if (
- isHeartbeatRunRuntimeStatusActive(run.status) &&
- logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >=
- ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS
- ) {
- lastLogRuntimeStatusTouchMs = logActivityAt.getTime();
- const touchedStatus = touchHeartbeatRunRuntimeStatus({
- companyId: run.companyId,
- issueId,
- agentId: run.agentId,
- runId: run.id,
- at: logActivityAt,
- });
- if (touchedStatus)
- publishHeartbeatRunRuntimeProgress(touchedStatus);
- }
-
- const payloadChunk =
- sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
- ? sanitizedChunk.slice(
- sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES,
- )
- : sanitizedChunk;
-
- publishLiveEvent({
- companyId: run.companyId,
- type: "heartbeat.run.log",
- payload: {
- runId: run.id,
- agentId: run.agentId,
- issueId,
- ts,
- seq: chunkSeq,
- stream,
- chunk: payloadChunk,
- truncated: payloadChunk.length !== sanitizedChunk.length,
- },
- });
- };
- const onLog = (stream: "stdout" | "stderr", chunk: string) =>
- appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk));
+ const { currentUserRedactionOptions, appendIdentityRedactedLog, onLog, onAdapterMeta, onAdapterEvent } =
+ await runOutput.begin(currentRun);
if (runScopedMentionedSkillKeys.length > 0) {
await onLog(
"stdout",
@@ -3683,36 +3542,6 @@ export function heartbeatService(
);
}
}
- const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
- meta = identityRedactor.redact(meta);
- if (meta.env && secretKeys.size > 0) {
- for (const key of secretKeys) {
- if (key in meta.env) meta.env[key] = "***REDACTED***";
- }
- }
- await appendRunEvent(currentRun, {
- eventType: "adapter.invoke",
- stream: "system",
- level: "info",
- message: "adapter invocation",
- payload: meta as unknown as Record,
- });
- };
-
- const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
- event = identityRedactor.redact(event);
- const eventType = event.eventType.trim();
- if (!eventType) return;
- await appendRunEvent(currentRun, {
- eventType: eventType.slice(0, 120),
- stream: event.stream,
- level: event.level,
- color: event.color,
- message: event.message,
- payload: event.payload,
- });
- };
-
const adapter = getServerAdapter(agent.adapterType);
const durableGoalControlRun =
readNonEmptyString(context.goalControlRequestId) !== null ||
@@ -4101,11 +3930,7 @@ export function heartbeatService(
}
await runCompletion.completeRun({
run, agent, issueId, issueRef, signal: executionControl.controller.signal,
- output: {
- handle, outputProgressState, flushOutputProgress,
- get stdoutExcerpt() { return stdoutExcerpt; },
- get stderrExcerpt() { return stderrExcerpt; },
- },
+ output: runOutput,
session: { runtimeForAdapter, previousSessionParams, taskKey, configuredModel, sessionConfigMetadata },
readFailureReportSecrets,
adapterResult, sessionCodec, taskSessionForRun, sessionCompaction,
@@ -4236,11 +4061,7 @@ export function heartbeatService(
}
await runCompletion.failRun({
run, agent, issueId, issueRef, signal: executionControl.controller.signal,
- output: {
- handle, outputProgressState, flushOutputProgress,
- get stdoutExcerpt() { return stdoutExcerpt; },
- get stderrExcerpt() { return stderrExcerpt; },
- },
+ output: runOutput,
session: { runtimeForAdapter, previousSessionParams, taskKey, configuredModel, sessionConfigMetadata },
readFailureReportSecrets,
runId, err, identityRedactor, requiredWorkspaceRestoreEvidence, legacyAdapterEntered,
diff --git a/server/src/services/heartbeat/run-output.ts b/server/src/services/heartbeat/run-output.ts
new file mode 100644
index 00000000000..249db52646e
--- /dev/null
+++ b/server/src/services/heartbeat/run-output.ts
@@ -0,0 +1,209 @@
+import { eq } from "drizzle-orm";
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import type { AdapterInvocationMeta, AdapterRuntimeEvent } from "../../adapters/index.js";
+import { redactCurrentUserText, type CurrentUserRedactionOptions } from "../../log-redaction.js";
+import type { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
+import { touchHeartbeatRunRuntimeStatus } from "../heartbeat-run-runtime-status.js";
+import { publishLiveEvent } from "../live-events.js";
+import type { RunLogHandle, RunLogStore } from "../run-log-store.js";
+import { appendExcerpt, compactRunLogChunk } from "./run-log.js";
+import { isHeartbeatRunRuntimeStatusActive, publishHeartbeatRunRuntimeProgress, type createHeartbeatLifecycle } from "./run-lifecycle.js";
+
+const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
+export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000;
+export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000;
+
+type Run = typeof heartbeatRuns.$inferSelect;
+
+export interface HeartbeatRunOutputInput {
+ run: Run;
+ runId: string;
+ issueId: string | null;
+ runLogStore: Pick;
+ identityRedactor: Pick, "chunk" | "redact">;
+ secretKeys: Set;
+ appendRunEvent: ReturnType["appendRunEvent"];
+ getCurrentUserRedactionOptions: () => Promise;
+}
+
+/** One recorder per execution attempt; the log store itself stays service-scoped. */
+export function createHeartbeatRunOutput(db: Db, input: HeartbeatRunOutputInput) {
+ const { run, runId, issueId, runLogStore, identityRedactor, secretKeys, appendRunEvent, getCurrentUserRedactionOptions } = input;
+ let handle: RunLogHandle | null = null;
+ let stdoutExcerpt = "";
+ let stderrExcerpt = "";
+ let outputSeq = Number(run.lastOutputSeq ?? 0);
+ let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null;
+ let lastLogRuntimeStatusTouchMs = 0;
+ const outputProgressState: {
+ pending: {
+ at: Date;
+ seq: number;
+ stream: "stdout" | "stderr";
+ bytes: number;
+ } | null;
+ } = { pending: null };
+ let persistedLogBytes = Number(run.logBytes ?? 0);
+ const flushOutputProgress = async (opts?: { force?: boolean }) => {
+ const pendingOutputProgress = outputProgressState.pending;
+ if (!pendingOutputProgress) return;
+ const shouldFlush =
+ opts?.force === true ||
+ !lastOutputFlushAt ||
+ pendingOutputProgress.at.getTime() - lastOutputFlushAt.getTime() >=
+ ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS;
+ if (!shouldFlush) return;
+ await db
+ .update(heartbeatRuns)
+ .set({
+ lastOutputAt: pendingOutputProgress.at,
+ lastOutputSeq: pendingOutputProgress.seq,
+ lastOutputStream: pendingOutputProgress.stream,
+ lastOutputBytes: pendingOutputProgress.bytes,
+ updatedAt: new Date(),
+ })
+ .where(eq(heartbeatRuns.id, run.id));
+ lastOutputFlushAt = pendingOutputProgress.at;
+ outputProgressState.pending = null;
+ };
+ // Bind handlers to the run row persisted at execution start. Progress writes
+ // above use only the immutable run ID from the initial row.
+ async function begin(currentRun: Run) {
+ const run = currentRun;
+ handle = await runLogStore.begin({
+ companyId: run.companyId,
+ agentId: run.agentId,
+ runId,
+ });
+
+ await db
+ .update(heartbeatRuns)
+ .set({
+ logStore: handle.store,
+ logRef: handle.logRef,
+ updatedAt: new Date(),
+ })
+ .where(eq(heartbeatRuns.id, runId));
+
+ const currentUserRedactionOptions =
+ await getCurrentUserRedactionOptions();
+ const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => {
+ const sanitizedChunk = compactRunLogChunk(
+ redactCurrentUserText(chunk, currentUserRedactionOptions),
+ );
+ if (stream === "stdout")
+ stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
+ if (stream === "stderr")
+ stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
+ const ts = new Date().toISOString();
+
+ outputSeq += 1;
+ const chunkSeq = outputSeq;
+ let appendedBytes = 0;
+ if (handle) {
+ appendedBytes = await runLogStore.append(handle, {
+ stream,
+ chunk: sanitizedChunk,
+ ts,
+ seq: chunkSeq,
+ });
+ persistedLogBytes += appendedBytes;
+ }
+ outputProgressState.pending = {
+ at: new Date(ts),
+ seq: chunkSeq,
+ stream,
+ bytes: persistedLogBytes,
+ };
+ await flushOutputProgress();
+
+ // Streamed CLI output is real run activity: keep the in-memory
+ // runtime status ("Working... / X ago") fresh between structured
+ // events so sandbox runs with mid-run log streaming never show a
+ // minutes-stale timestamp. Throttled to avoid churning the live
+ // event stream on every 250ms tail chunk.
+ const logActivityAt = new Date(ts);
+ if (
+ isHeartbeatRunRuntimeStatusActive(run.status) &&
+ logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >=
+ ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS
+ ) {
+ lastLogRuntimeStatusTouchMs = logActivityAt.getTime();
+ const touchedStatus = touchHeartbeatRunRuntimeStatus({
+ companyId: run.companyId,
+ issueId,
+ agentId: run.agentId,
+ runId: run.id,
+ at: logActivityAt,
+ });
+ if (touchedStatus)
+ publishHeartbeatRunRuntimeProgress(touchedStatus);
+ }
+
+ const payloadChunk =
+ sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
+ ? sanitizedChunk.slice(
+ sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES,
+ )
+ : sanitizedChunk;
+
+ publishLiveEvent({
+ companyId: run.companyId,
+ type: "heartbeat.run.log",
+ payload: {
+ runId: run.id,
+ agentId: run.agentId,
+ issueId,
+ ts,
+ seq: chunkSeq,
+ stream,
+ chunk: payloadChunk,
+ truncated: payloadChunk.length !== sanitizedChunk.length,
+ },
+ });
+ };
+ const onLog = (stream: "stdout" | "stderr", chunk: string) =>
+ appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk));
+ const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
+ meta = identityRedactor.redact(meta);
+ if (meta.env && secretKeys.size > 0) {
+ for (const key of secretKeys) {
+ if (key in meta.env) meta.env[key] = "***REDACTED***";
+ }
+ }
+ await appendRunEvent(currentRun, {
+ eventType: "adapter.invoke",
+ stream: "system",
+ level: "info",
+ message: "adapter invocation",
+ payload: meta as unknown as Record,
+ });
+ };
+
+ const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
+ event = identityRedactor.redact(event);
+ const eventType = event.eventType.trim();
+ if (!eventType) return;
+ await appendRunEvent(currentRun, {
+ eventType: eventType.slice(0, 120),
+ stream: event.stream,
+ level: event.level,
+ color: event.color,
+ message: event.message,
+ payload: event.payload,
+ });
+ };
+
+ return { currentUserRedactionOptions, appendIdentityRedactedLog, onLog, onAdapterMeta, onAdapterEvent };
+ }
+
+ return {
+ begin,
+ get handle() { return handle; },
+ outputProgressState,
+ flushOutputProgress,
+ // Finalization awaits I/O and can receive more output before persisting excerpts.
+ get stdoutExcerpt() { return stdoutExcerpt; },
+ get stderrExcerpt() { return stderrExcerpt; },
+ };
+}
diff --git a/tests/e2e/workspace-bootstrap/recovery.spec.ts b/tests/e2e/workspace-bootstrap/recovery.spec.ts
index 884a20a8bdc..e5136159860 100644
--- a/tests/e2e/workspace-bootstrap/recovery.spec.ts
+++ b/tests/e2e/workspace-bootstrap/recovery.spec.ts
@@ -75,6 +75,12 @@ for (const persistent of [false, true]) {
// Independently confirm the configured source was never overwritten.
expect(sourceCopy).toBeTruthy();
expect(completed.scheduledRetryAttempt).toBe(1);
+ const completedRun = await api(`/heartbeat-runs/${completed.id}`);
+ const completedLog = await api(`/heartbeat-runs/${completed.id}/log`);
+ expect(completedRun.stdoutExcerpt).toContain("Workspace recovery verification complete.");
+ expect(completedRun.lastOutputSeq).toBeGreaterThan(0);
+ expect(completedRun.lastOutputBytes).toBe(completedRun.logBytes);
+ expect(completedLog.content).toContain("Workspace recovery verification complete.");
expect(await readFile(path.join(fixture.source, "README.md"), "utf8")).toBe("Existing uncommitted work\n");
}
await page.reload();
From 914b70572c9a03348532e5e4ab9533927416cf83 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 12 Oct 2026 10:01:00 -0500
Subject: [PATCH 11/23] refactor(heartbeat): extract execution configuration
(#15973)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat turns task, agent, environment, and account settings into
an executable run.
> - The executor still assembles scoped credentials and skill
configuration inline between environment selection and session
preparation.
> - That phase has a clear input/output boundary, but credential
acquisition must remain visible to cleanup even when later setup fails.
> - This pull request extracts configuration assembly and immediately
notifies the executor of acquired resources and redaction state.
> - The executor retains admission, dispatch, and cleanup, with the same
statement ordering and explicit busy-account deferral.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Maintenance of heartbeat execution. This PR follows #15969 and #15966,
both now merged. The branch is rebased onto `master`; `git range-diff`
confirms that the extraction patch is unchanged.
**Current behavior**
`heartbeat.ts` has 4,561 lines and 188,950 bytes. Configuration assembly
mixes workspace overrides, secrets, managed AI preparation, and skill
delivery into the executor.
**Proposed behavior**
`execution-configuration.ts` owns this preparation phase in 269 lines
and 16,204 bytes. The executor calls it between environment and session
preparation and retains immediate cleanup ownership. `heartbeat.ts`
becomes 4,372 lines and 178,459 bytes.
**Reason and benefit**
The execution path exposes another named phase, with tests that exercise
its effective configuration, resource lifetime, failure diagnostics, and
busy-account result through public heartbeat dispatch. Related work:
#11827 changes pre-dispatch secret refresh and #15355 changes GitHub
delegation; this extraction preserves current policies.
**Breaking changes**
None intended. Public exports remain available, and the original helpers
keep their modules.
## What Changed
- Move configuration merging, scoped secret resolution, managed AI
acquisition, runtime skills, connector instructions, and managed
identity preparation into one typed module.
- Notify the executor immediately when failure-redaction values, managed
credentials, and identity redaction become available. Returning
resources only after all preparation succeeds would lose cleanup on
partial failure.
- Preserve the live failure-secret getter for later runtime environment
changes. Return an explicit deferred result for busy accounts.
- Add four real-database characterization tests through
`heartbeat.wakeup`, using actual credential files and cleanup with a
fixture adapter.
- Update the developer module map.
## Verification
- Before extraction: all 45 tests pass across the new public-path tests
and existing project-environment tests.
- After extraction: 47 tests pass across execution configuration,
project environment, and runtime skills. No skips.
- Two additional real-process managed identity tests pass; nine
unrelated cases are deliberately filtered out.
- `pnpm -r typecheck` and `pnpm build` pass.
- Normalized syntax comparison confirms the moved body and reconstructed
executor match after accounting for immediate ownership callbacks and
the explicit prepared/deferred result.
- Isolated browser acceptance: 2/2 pass (3.9 minutes). A real process
worker completes after Git recovery, with persisted stdout and matching
final output byte counts. Persistent failures stop after three attempts.
Both outcomes survive reload. The disposable server was shut down after
verification.
- All eleven new public-path characterization cases across the three-PR
stack pass together on the final commit.
- All 140 named public exports from the batch baseline remain available.
- CI initially failed the legacy-continuation message browser case:
periodic recovery escalated the issue, then queue admission cancelled
the run with `execution_reconciliation_required` before configuration
preparation. The unchanged predecessor shard passes, and the exact case
passes locally (5 seconds in the browser). The original failing case
passed on its single rerun (8.2 seconds), but GitHub then shut down the
runner during a different test (`The runner has received a shutdown
signal`). The separate infrastructure restart completed: 24 browser
cases passed, with two existing optional cases skipped. Assertions and
timeouts are unchanged.
- Greptile: 5/5 on `27ab7e3df64c157ba1f00c5757db812ff7a4e5aa`, with no
actionable findings. Hosted full-suite CI passes on this exact commit:
all 54 check runs and the additional commit status are successful.
- Merge preparation: all eleven characterization tests pass after
rebasing onto `master`. Fresh full CI passes for
`1758673f87eaa76a8b2c520eac4b631ada5d9ba1`: 55 successful checks and two
optional Storybook skips, including all eight browser shards, typecheck,
tests, and build. Greptile is 5/5 on this exact commit with zero review
threads. All heartbeat source, tests, documentation, and browser
assertions are identical to the previously reviewed final stack.
## Risks
- A failure after AI acquisition must still remove the disposable
credential home. The new public-path failure test proves actual
filesystem cleanup and diagnostic redaction before the external
reporting sink.
- The baseline exposed an existing gap: arbitrary exception text in the
local `run.error` field can contain a credential even though the failure
reporter redacts it. This behavior is unchanged and merits a separate
fix; the test does not claim the local error field is sanitized.
- Account-busy deferral must return without dispatch while retaining its
scheduled retry. The new test checks the durable run states.
- Browser acceptance uses a real process fixture and disposable data,
without live provider calls.
## Model Used
OpenAI Codex, based on GPT-6, assisted with reasoning, code edits, tool
execution, and verification. The exact deployment model ID 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
---
doc/DEVELOPING.md | 8 +
.../heartbeat-execution-configuration.test.ts | 160 +++++++++++
server/src/services/heartbeat.ts | 225 ++-------------
.../heartbeat/execution-configuration.ts | 269 ++++++++++++++++++
4 files changed, 455 insertions(+), 207 deletions(-)
create mode 100644 server/src/__tests__/heartbeat-execution-configuration.test.ts
create mode 100644 server/src/services/heartbeat/execution-configuration.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index da1bc58a960..90b2760794e 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1293,6 +1293,14 @@ cleanup. Public-path characterization tests are in
browser fixture also checks the persisted prompt variants and description revision
from a real process adapter before completing its task.
+Execution configuration is assembled in
+`server/src/services/heartbeat/execution-configuration.ts`: workspace and issue
+overrides, scoped credentials, managed AI identity, and runtime skill delivery.
+The executor receives credential ownership and failure-redaction updates as soon
+as they are available, so later setup failures still clean up acquired resources.
+Busy-account deferral returns explicitly before session/workspace preparation.
+Public-path tests are in `server/src/__tests__/heartbeat-execution-configuration.test.ts`.
+
Session preparation is in `server/src/services/heartbeat/session-preparation.ts`.
It selects fresh or resumable sessions from effective config and managed-account
identity, then resolves adapter session identity and compaction after workspace
diff --git a/server/src/__tests__/heartbeat-execution-configuration.test.ts b/server/src/__tests__/heartbeat-execution-configuration.test.ts
new file mode 100644
index 00000000000..ee1dbd66298
--- /dev/null
+++ b/server/src/__tests__/heartbeat-execution-configuration.test.ts
@@ -0,0 +1,160 @@
+import { randomUUID } from "node:crypto";
+import { access, readFile } from "node:fs/promises";
+import path from "node:path";
+import { eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { agents, companies, companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
+import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
+import { HttpError } from "../errors.js";
+import { heartbeatService } from "../services/heartbeat.js";
+import { aiConnectionService } from "../services/ai-connections.js";
+import * as aiRuntime from "../services/ai-connection-runtime.js";
+import * as sentry from "../sentry.js";
+import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+const skillFailures = vi.hoisted(() => new Map());
+vi.mock("../services/company-skills.js", async importOriginal => {
+ const actual = await importOriginal();
+ return { ...actual, companySkillService: (...args: Parameters) => {
+ const service = actual.companySkillService(...args);
+ return { ...service, listRuntimeSkillEntries: async (...input: Parameters) => {
+ const failure = skillFailures.get(input[0]);
+ if (failure) throw new Error(failure);
+ return service.listRuntimeSkillEntries(...input);
+ } };
+ } };
+});
+
+const support = await getEmbeddedPostgresTestSupport();
+const describePostgres = support.supported ? describe : describe.skip;
+if (!support.supported) console.warn(`Skipping execution configuration tests: ${support.reason}`);
+
+describePostgres("heartbeat execution configuration through adapter dispatch", () => {
+ let temporary: Awaited>;
+ let db: ReturnType;
+ let heartbeat: ReturnType;
+ const captured = new Map();
+ const inspect = new Map Promise>();
+ const adapterType = "codex_local";
+
+ beforeAll(async () => {
+ temporary = await startEmbeddedPostgresTestDatabase("heartbeat-execution-config-");
+ db = createDb(temporary.connectionString);
+ heartbeat = heartbeatService(db);
+ // Exercise real preparation for a managed-capable adapter without calling a provider.
+ registerServerAdapter({
+ type: adapterType, supportsLocalAgentJwt: false,
+ execute: async input => {
+ captured.set(input.runId, input);
+ await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
+ await inspect.get(input.agent.id)?.(input);
+ return { exitCode: 0, signal: null, timedOut: false, resultJson: {} };
+ },
+ testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
+ });
+ }, 30_000);
+ afterEach(() => { vi.restoreAllMocks(); skillFailures.clear(); });
+ afterAll(async () => {
+ await heartbeat?.drainActiveRunExecutions();
+ unregisterServerAdapter(adapterType);
+ await db?.$client.end({ timeout: 0 });
+ await temporary?.cleanup();
+ });
+
+ async function seed(managed = false) {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), projectId = randomUUID();
+ const userId = `configuration-user-${companyId}`;
+ const credential = `fixture-api-key-${companyId}`;
+ await db.insert(companies).values({ id: companyId, name: "Execution configuration fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
+ await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
+ const account = managed ? await aiConnectionService(db).save(companyId, userId, {
+ provider: "openai", method: "api_key", ownership: "personal", name: "Fixture account", apiKey: credential, agentIds: [], allAgents: true,
+ }, credential) : null;
+ await db.insert(projects).values({ id: projectId, companyId, name: "Config project", env: { SHARED_VALUE: "project", PROJECT_ONLY: "project-only" } });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Configuration reader", adapterType, status: "idle",
+ adapterConfig: { model: "agent-model", env: { SHARED_VALUE: "agent", AGENT_ONLY: "agent-only", ...(managed ? { OPENAI_API_KEY: "stale-agent-key" } : {}) } },
+ runtimeConfig: account ? { aiConnection: { provider: "openai", method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } } : {},
+ });
+ await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Configuration fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1, assigneeAdapterOverrides: { adapterConfig: { model: "issue-model" } } });
+ return { companyId, agentId, issueId, credential };
+ }
+
+ async function execute(f: Awaited>) {
+ const run = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual", payload: { issueId: f.issueId }, contextSnapshot: {
+ issueId: f.issueId, paperclipSecrets: { manifest: [{ secretKey: "untrusted-wake-value" }] },
+ paperclipWake: { connectorSkillInstructions: "untrusted wake instructions" },
+ } });
+ expect(run).not.toBeNull();
+ await heartbeat.drainActiveRunExecutions();
+ const [finished] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id));
+ expect(finished).toBeDefined();
+ return { finished, input: captured.get(run!.id) };
+ }
+
+ it("applies project environment and issue overrides and replaces caller-supplied runtime fields", async () => {
+ const f = await seed();
+ const { finished, input } = await execute(f);
+ expect(finished.status, finished.error ?? undefined).toBe("succeeded");
+ expect(input?.config).toMatchObject({ model: "issue-model", env: { SHARED_VALUE: "project", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only" } });
+ expect(input?.context.paperclipSecrets).toBeUndefined();
+ expect(JSON.stringify(input?.context.paperclipWake)).not.toContain("untrusted wake instructions");
+ });
+
+ it("delivers the selected credential in an isolated home and removes it after completion", async () => {
+ const f = await seed(true);
+ let providerHome: string | undefined;
+ inspect.set(f.agentId, async input => {
+ const env = input.config.env as Record;
+ providerHome = env.CODEX_HOME;
+ expect(env.OPENAI_API_KEY).toBe(f.credential);
+ expect(env.CODEX_API_KEY).toBe(f.credential);
+ expect(JSON.parse(await readFile(path.join(providerHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: f.credential });
+ await input.onMeta?.({ adapterType, command: "fixture", env, prompt: "fixture" });
+ });
+ const { finished, input } = await execute(f);
+ expect(finished.status, finished.error ?? undefined).toBe("succeeded");
+ expect(input?.context.aiConnection).toMatchObject({ accountName: "Fixture account", provider: "openai" });
+ expect(providerHome).toEqual(expect.any(String));
+ await expect(access(providerHome!)).rejects.toMatchObject({ code: "ENOENT" });
+ const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
+ expect(events.some(event => event.eventType === "adapter.invoke")).toBe(true);
+ expect(JSON.stringify(events)).not.toContain(f.credential);
+ });
+
+ it("cleans acquired credentials and redacts diagnostics when later skill preparation fails", async () => {
+ const f = await seed(true);
+ skillFailures.set(f.companyId, `Invalid fixture skill configuration: ${f.credential}`);
+ const capturedFailure = vi.spyOn(sentry, "captureRunFailure").mockImplementation(() => undefined);
+ const prepare = aiRuntime.prepareManagedAiRuntime;
+ let acquiredHome: string | undefined;
+ vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockImplementation(async (...args) => {
+ const runtime = await prepare(...args);
+ acquiredHome = runtime.home;
+ await access(acquiredHome!);
+ return runtime;
+ });
+ const { finished, input } = await execute(f);
+ expect(finished.status).toBe("failed");
+ expect(input).toBeUndefined();
+ expect(acquiredHome).toEqual(expect.any(String));
+ await expect(access(acquiredHome!)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(finished.error).toContain("Invalid fixture skill configuration");
+ // The diagnostic reporter redacts runtime values before the external sink.
+ // The legacy local run.error field does not apply this value-based redaction.
+ await waitForPendingRunFailureReports();
+ expect(capturedFailure).toHaveBeenCalled();
+ expect(JSON.stringify(capturedFailure.mock.calls)).toContain("Invalid fixture skill configuration");
+ expect(JSON.stringify(capturedFailure.mock.calls)).not.toContain(f.credential);
+ });
+
+ it("defers a busy account without dispatch and preserves its scheduled retry", async () => {
+ const f = await seed(true);
+ vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockRejectedValueOnce(new HttpError(422, "AI credentials are being updated. Retry shortly.", { code: "ai_connection_busy" }));
+ const { finished, input } = await execute(f);
+ expect(input).toBeUndefined();
+ expect(finished).toMatchObject({ status: "cancelled", errorCode: "ai_connection_busy", resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false } }, contextSnapshot: { aiConnectionBusyDeferredWhileAssignee: true } });
+ const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agentId));
+ expect(runs.filter(run => run.id !== finished.id)).toEqual([expect.objectContaining({ status: "scheduled_retry", scheduledRetryReason: "ai_connection_busy" })]);
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 54006b8b7ef..8f562fb8627 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { prepareHeartbeatExecutionConfiguration } from "./heartbeat/execution-configuration.js";
import { createHeartbeatRunOutput } from "./heartbeat/run-output.js";
import { prepareHeartbeatSession, resolveHeartbeatAdapterSession } from "./heartbeat/session-preparation.js";
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
@@ -113,9 +114,6 @@ import {
resolveAcceptedPlanWakeRoutingDecision,
clearInteractionContinuationWakeContext,
PAPERCLIP_WAKE_PAYLOAD_KEY,
- resolveRunScopedMentionedSkillKeys,
- resolveExecutionRunAdapterConfig,
- applyRunScopedMentionedSkillKeys,
MANAGED_GITHUB_TOKEN_KEYS,
configuredPaperclipApiBaseUrl,
createHeartbeatRunPreparation,
@@ -134,8 +132,6 @@ export {
buildPaperclipWakePayload,
} from "./heartbeat/run-preparation.js";
import {
- buildExecutionWorkspaceConfigSnapshot,
- stripWorkspaceRuntimeFromExecutionRunConfig,
resolveRuntimeSessionParamsForWorkspace,
buildRunWorkspaceHints,
buildReferencedProjectRunObservability,
@@ -211,10 +207,7 @@ export { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
import { decisionModelService } from "./decision-models.js";
import { TASK_QUESTION_GUIDANCE } from "./issue-question-context.js";
import { createAgentIdentityRedactor } from "./agent-identity-redaction.js";
-import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js";
-import { prepareConnectionInstructionDelivery } from "./connection-instructions.js";
-import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js";
import {
startupCancellationFence,
@@ -250,19 +243,13 @@ import {
watchLegacyControllerLease,
} from "./legacy-controller-lease.js";
-import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
-import { connectionIntentService } from "./connection-intents.js";
import {
prepareManagedAiRuntime,
assertManagedAiProjectAuth,
- stripAiAuthBindings,
- isAiConnectionBusy,
- AI_AUTH_ENV_KEYS,
} from "./ai-connection-runtime.js";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
-import { managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
import {
getExecutionBlocker,
} from "./execution-blocker.js";
@@ -336,7 +323,6 @@ import { HttpError, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import {
- resolveManagedGitHubIdentitySelection,
scrubGitCredentialText,
} from "./git-credentials.js";
// Re-exported because heartbeat's workspace surface exposed the scrubber before the
@@ -360,10 +346,6 @@ import {
resolveHeartbeatNativeRuntimeMode,
} from "./native-runtime/index.js";
-import {
- recordFailedSkillPreparation,
- type NativeRunHistoricalSpan,
-} from "./native-runtime/native-run-trace.js";
import {
parseNativeExecutionInput,
type NativeExecutionInput,
@@ -396,7 +378,6 @@ import {
emitAgentTaskRun,
emitAgentTaskRunById,
} from "./agent-task-run-telemetry.js";
-import { readAiConnectionConfigurationFailure, readAiCredentialAccessFailure } from "./ai-connection-configuration-failure.js";
import { reportRunFailure } from "./run-failure-report.js";
import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js";
@@ -463,7 +444,6 @@ import {
} from "./run-scratch.js";
import {
applyDefaultIsolatedExecutionWorkspacePolicy,
- buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
@@ -489,7 +469,6 @@ import {
type PostCommitEffect,
MAX_TURN_CONTINUATION_RETRY_REASON,
WORKSPACE_BUSY_RETRY_REASON,
- AI_CONNECTION_BUSY_RETRY_REASON,
AI_CONNECTION_POOL_WAIT_RETRY_REASON,
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
@@ -516,9 +495,6 @@ import {
import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
-import {
- readPaperclipSkillSyncPreference,
-} from "@paperclipai/adapter-utils/server-utils";
import { environmentService } from "./environments.js";
@@ -526,7 +502,6 @@ import {
environmentRuntimeService,
type ProviderResourceDisposition,
} from "./environment-runtime.js";
-import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import {
@@ -544,9 +519,6 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js";
-const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
- "paperclipai/paperclip/paperclip-converting-plans-to-tasks";
-
const execFile = promisify(execFileCallback);
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = [
"failed",
@@ -2698,185 +2670,24 @@ export function heartbeatService(
config: { config, issueAssigneeOverrides },
services: { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder },
});
- const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
- agentConfig: config,
- projectPolicy: projectExecutionWorkspacePolicy,
- issueSettings: issueExecutionWorkspaceSettings,
- mode: requestedExecutionWorkspaceMode,
- legacyUseProjectWorkspace:
- issueAssigneeOverrides?.useProjectWorkspace ?? null,
+ const executionConfiguration = await prepareHeartbeatExecutionConfiguration(db, {
+ run, agent,
+ task: { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, projectContext, routineEnvContext },
+ config: { config, issueAssigneeOverrides, requestedAiBinding, aiBinding },
+ policy: { trustPreset, projectExecutionWorkspacePolicy, issueExecutionWorkspaceSettings, requestedExecutionWorkspaceMode, useIsolatedTaskDirectory },
+ environment: { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput },
+ services: { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral },
+ onFailureSecretsResolved: (readSecrets) => { readFailureReportSecrets = readSecrets; },
+ onManagedAiRuntimeAcquired: (runtime) => { managedAiRuntime = runtime; },
+ onIdentityRedactorPrepared: (redactor) => { identityRedactor = redactor; },
});
- const mergedConfig = {
- ...workspaceManagedConfig,
- ...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))),
- ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}),
- // The base below is already task-owned. Keep directory transport while
- // preserving isolated mode and the mandatory sandbox preflight.
- ...(useIsolatedTaskDirectory ? { workspaceStrategy: { type: "project_primary" } } : {}),
- };
- const configSnapshot = buildExecutionWorkspaceConfigSnapshot(
- mergedConfig,
- selectedEnvironmentId,
- );
- const executionRunConfig =
- stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
- const runScopedMentionedSkillKeys =
- await resolveRunScopedMentionedSkillKeys({
- db,
- companyId: agent.companyId,
- issueId,
- commentIds: extractWakeCommentIds(context),
- });
- const runScopedSkillKeys =
- acceptedPlanContinuationWake &&
- !acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
- ? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
- : runScopedMentionedSkillKeys;
- const githubSelection = await resolveManagedGitHubIdentitySelection(
- db,
- agent.companyId,
- {
- agentId: agent.id,
- responsibleUserId,
- allowStandingDelegation: false,
- },
- );
- const useHostGitHub =
- !githubSelection.configured &&
- trustPreset.kind === "standard" &&
- ["local", "ssh"].includes(
- selectedEnvironmentForConfig?.driver ?? "local",
- );
-
- const { resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest } =
- await resolveExecutionRunAdapterConfig({
- managedAiCredentials: Boolean(aiBinding),
- managedGitHubCredentials: !useHostGitHub,
- companyId: agent.companyId,
- agentId: agent.id,
- adapterType: agent.adapterType,
- issueId,
- heartbeatRunId: run.id,
- environmentId: selectedEnvironmentForConfig?.id ?? null,
- environmentEnv: aiBinding ? stripAiAuthBindings(selectedEnvironmentForConfig?.envVars) : selectedEnvironmentForConfig?.envVars ?? null,
- environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
- projectId: projectContext?.id ?? null,
- routineId: routineEnvContext.routineId,
- responsibleUserId,
- executionRunConfig: aiBinding ? { ...executionRunConfig, env: stripAiAuthBindings(executionRunConfig.env) } : executionRunConfig,
- projectEnv: aiBinding ? stripAiAuthBindings(projectContext?.env) : projectContext?.env ?? null,
- routineEnv: aiBinding ? stripAiAuthBindings(routineEnvContext.env) : routineEnvContext.env,
- secretsSvc,
- trustPreset,
- });
- readFailureReportSecrets = () => collectRunFailureSecretValues(resolvedConfig.env, secretKeys);
- if (aiBinding) {
- try {
- managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
- } catch (error) {
- // Only fresh executions can receive a pre-provider wait receipt. A
- // persisted native input may already have provider effects to recover.
- if (isAiConnectionBusy(error) && !persistedNativeExecutionInput) {
- // Use the authority recorded by the locked admission gate, never
- // the issue's mutable assignee observed during runtime preparation.
- const authorizedNonAssigneeWake =
- parseObject(run.runnerProfileJson).aiConnectionNonAssigneeCommentWake === true ||
- (run.scheduledRetryReason === AI_CONNECTION_BUSY_RETRY_REASON &&
- isNonAssigneeWorkspaceBusyRetry(run.scheduledRetryReason, parseObject(run.contextSnapshot)));
- await finalizeAiConnectionBusyDeferral(run, error, !authorizedNonAssigneeWake);
- return;
- }
- if (responsibleUserId && issueId) {
- await connectionIntentService(db).request({ sub: agent.id, company_id: agent.companyId, run_id: run.id, responsible_user_id: responsibleUserId }, aiBinding.provider, { purpose: "ai" }).catch(() => {
- logger.warn({ runId: run.id, agentId: agent.id }, "Could not attach AI connection request; runtime configuration action remains available");
- });
- }
- throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this agent’s AI connection", {
- configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId,
- ...(!persistedNativeExecutionInput && readAiConnectionConfigurationFailure(error)
- ? { selectionFailure: readAiConnectionConfigurationFailure(error) } : {}),
- ...(readAiCredentialAccessFailure(error)
- ? { credentialAccess: readAiCredentialAccessFailure(error) } : {}),
- provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`,
- fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` },
- });
- }
- const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection);
- if (persistedNativeExecutionInput && !managedAiSessionIdentityCompatible(
- savedAiAccount.sessionIdentity ?? savedAiAccount.identity,
- managedAiRuntime.sessionIdentity,
- managedAiRuntime.identity,
- )) {
- throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
- }
- Object.assign(resolvedConfig, managedAiRuntime.config);
- for (const key of AI_AUTH_ENV_KEYS) secretKeys.add(key);
- context.aiConnection = { ...managedAiRuntime.attribution, accountName: managedAiRuntime.accountName, identity: managedAiRuntime.identity, sessionIdentity: managedAiRuntime.sessionIdentity };
- await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection, ...(context.aiRouterTaskKey ? { aiRouterTaskKey: context.aiRouterTaskKey } : {}), ...(context.aiRouterSelection ? { aiRouterSelection: context.aiRouterSelection } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
- }
- if (secretManifest.length > 0) {
- context.paperclipSecrets = {
- manifest: secretManifest,
- };
- } else {
- delete context.paperclipSecrets;
- }
- const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
- resolvedConfig,
- runScopedSkillKeys,
- );
- const runtimeSkillPreference = readPaperclipSkillSyncPreference(
- effectiveResolvedConfig,
- );
- const nativeRunnerPreparationSpans: NativeRunHistoricalSpan[] = [];
- const skillsPrepareStartedAtMs = Date.now();
- const runtimeSkillEntries = await (async () => {
- try {
- return await companySkills.listRuntimeSkillEntries(agent.companyId, {
- versionSelections: skillVersionSelectionMap(
- runtimeSkillPreference.desiredSkillEntries,
- {
- versionPinsEnabled:
- resolvedInstanceSettings.experimental.enableBetaSkills ===
- true,
- },
- ),
- });
- } catch (error) {
- if (agent.adapterType === "paperclip_runner") {
- await recordFailedSkillPreparation({
- runId: run.id,
- startedAtMs: skillsPrepareStartedAtMs,
- onEvent: async (event) => {
- await appendRunEvent(run, event);
- },
- });
- }
- throw error;
- }
- })();
- nativeRunnerPreparationSpans.push({
- name: "skills.prepare",
- parentName: "task.prepare",
- startedAtMs: skillsPrepareStartedAtMs,
- endedAtMs: Date.now(),
- });
- const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, issueId: typeof context.issueId === "string" ? context.issueId : undefined });
- const connectorSkillConfig = await applyConnectorSkills(effectiveResolvedConfig, runtimeSkillEntries, connectorAssignments);
- // Both CLI adapters and native context materialization use the same resolved set.
- runtimeSkillEntries.splice(0, runtimeSkillEntries.length, ...connectorSkillConfig.paperclipRuntimeSkills);
- const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType);
- // Always replace this runtime-only field; caller wake data cannot supply skills.
- context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
- let runtimeConfig = await prepareConnectionInstructionDelivery({
- resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }),
- context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner",
- });
- const agentIdentity = supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run.runtimeMode === "native" ? run.driverKind : undefined)
- ? await agentIdentityService(db).ensureAgentIdentity(agent.companyId, agent.id)
- : undefined;
- identityRedactor = createAgentIdentityRedactor(agentIdentity?.privateKeyPem);
- if (agentIdentity) secretKeys.add("PAPERCLIP_AGENT_PRIVATE_KEY");
+ if (!executionConfiguration.prepared) return;
+ const {
+ mergedConfig, configSnapshot, runScopedMentionedSkillKeys, githubSelection, useHostGitHub,
+ resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest,
+ runtimeSkillEntries, nativeRunnerPreparationSpans, agentIdentity,
+ } = executionConfiguration;
+ let { runtimeConfig } = executionConfiguration;
const resolvedFailureSecrets = readFailureReportSecrets();
readFailureReportSecrets = () => [
...resolvedFailureSecrets,
diff --git a/server/src/services/heartbeat/execution-configuration.ts b/server/src/services/heartbeat/execution-configuration.ts
new file mode 100644
index 00000000000..859371de9aa
--- /dev/null
+++ b/server/src/services/heartbeat/execution-configuration.ts
@@ -0,0 +1,269 @@
+import { eq, sql } from "drizzle-orm";
+import { heartbeatRuns, type Db, type agents, type projects } from "@paperclipai/db";
+import type { AiConnectionBinding, AiRuntimeConnectionBinding } from "@paperclipai/shared";
+import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
+import { parseObject } from "../../adapters/utils.js";
+import { logger } from "../../middleware/logger.js";
+import { AI_CONNECTION_BUSY_RETRY_REASON, extractWakeCommentIds, isNonAssigneeWorkspaceBusyRetry } from "../../modules/run-dispatch/index.js";
+import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
+import { agentIdentityService, supportsManagedAgentIdentity } from "../agent-identity.js";
+import { readAiConnectionConfigurationFailure, readAiCredentialAccessFailure } from "../ai-connection-configuration-failure.js";
+import { AI_AUTH_ENV_KEYS, isAiConnectionBusy, prepareManagedAiRuntime, stripAiAuthBindings } from "../ai-connection-runtime.js";
+import { managedAiSessionIdentityCompatible } from "../ai-connection-session.js";
+import type { companySkillService } from "../company-skills.js";
+import { connectionIntentService } from "../connection-intents.js";
+import { prepareConnectionInstructionDelivery } from "../connection-instructions.js";
+import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "../connector-runtime.js";
+import { buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js";
+import { resolveManagedGitHubIdentitySelection } from "../git-credentials.js";
+import { resolveAssignedConnectionInstructionsForRun } from "../native-runtime/assigned-mcp-tools.js";
+import { recordFailedSkillPreparation, type NativeRunHistoricalSpan } from "../native-runtime/native-run-trace.js";
+import { collectRunFailureSecretValues } from "../run-failure-diagnostics.js";
+import { skillVersionSelectionMap } from "../runtime-skill-selections.js";
+import type { selectHeartbeatEnvironment } from "./environment-selection.js";
+import type { createHeartbeatRetries } from "./retries.js";
+import type { createHeartbeatLifecycle } from "./run-lifecycle.js";
+import {
+ ConfigurationIncompleteFailure, applyRunScopedMentionedSkillKeys, resolveExecutionRunAdapterConfig,
+ resolveRunScopedMentionedSkillKeys, type createHeartbeatRunPreparation, type resolveAcceptedPlanWakeRoutingDecision,
+} from "./run-preparation.js";
+import { buildExecutionWorkspaceConfigSnapshot, stripWorkspaceRuntimeFromExecutionRunConfig } from "./workspaces.js";
+import type { HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
+
+const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
+ "paperclipai/paperclip/paperclip-converting-plans-to-tasks";
+
+type ManagedAiRuntime = Awaited>;
+type EnvironmentSelection = Awaited>;
+
+export interface HeartbeatExecutionConfigurationInput {
+ run: typeof heartbeatRuns.$inferSelect;
+ agent: typeof agents.$inferSelect;
+ task: {
+ issueId: string | null;
+ context: Record;
+ responsibleUserId: string | null;
+ acceptedPlanContinuationWake: boolean;
+ acceptedPlanWakeRoutingDecision: Awaited> | null;
+ projectContext: Pick | null;
+ routineEnvContext: Awaited["getRoutineEnvForExecutionIssue"]>>;
+ };
+ config: {
+ config: Record