fix(grok): avoid routine wakes for proactive admission (#16422)

## Thinking Path

> - Paperclip lets people manage AI agents and their work.
> - Grok Bot can request governed work from its own conversation.
> - Managed Runner admission can take longer than the initial MCP
response.
> - The dispatcher treated a delayed offer as a reason to start another
routine.
> - The original conversation was already waiting for that same work.
> - This pull request keeps admission in its original request and
cancels old prepared echoes.
> - Users get one task interaction while normal assigned-task wakes
remain available.

## Linked Issues or Issue Description

Refs #16219 and #16394. The duplicate search found no other proactive
Grok correction.

**What happened?**

A real idle Grok conversation created one Paperclip task and reached
accepted completion. During admission, the dispatcher also sent a
routine webhook after the offer was ten seconds old. Two acceptance
requests and two title writes were recorded. The extra routine can
compete with the original conversation.

**Expected behavior**

Immediate and delayed Bot-initiated admission stay with the original
request. The Bot checks the same request ID and arguments until
admission succeeds or fails. A new routine must not echo that work back
to the Bot.

**Steps to reproduce**

1. Ask an idle Grok Bot to create and complete a Paperclip task.
2. Let managed admission return pending.
3. Leave its offered assignment unaccepted for more than ten seconds.
4. Run the dispatcher. It sends another routine webhook for that
proactive offer.

**Paperclip version or commit**

Observed through the actual Grok client on Core
11d69d04c7. Both new wake regressions also
fail on current master before this correction.

## What Changed

- Remove the delayed proactive-admission wake scan.
- Fence prepared proactive wakes using the current binding and
generation. A dispatcher restart cancels those old intents without
sending them.
- Tell Grok to keep checking its original queued request. Preserve Dot
event instructions and normal assigned-task dispatch.
- Add delayed-offer and recovered-intent regressions. Update the agent
guide and release evidence plan.

## Verification

- Two new network regressions fail before the fix. The corrected Grok
and Dot suites pass all 80 checks.
- The full focused Grok, Dot, MCP authorization/shutdown and
question-delivery selection passes 214 checks across six files.
- Repository typecheck and build pass. Token gates, the 2,731-story
structural catalog and Storybook build pass. All these checks also pass
after the branch was rebased onto master
250429942e.
- The real-client investigation created one task and one managed run,
saved a 100-word report, reached canonical Done and recorded successful
lease cleanup. Its exact public source marker was absent from the
operator prompt, and no Paperclip proxy operation fetched it. The
no-echo condition failed on that source; this PR still requires a
deployed real-client repeat.
- Public synthetic marker for the deployed repeat:
`grok-native-read-8731cd8120b140829fbe476f6974fde8`.
- Current-head CI passes with 55 successful checks and two optional
Storybook skips. Greptile reviewed
e6b828af28 at 5/5 with no findings.
- The first workspace CI attempt hit a 5,000ms timeout in the unchanged
snapshot-worker command-preparation test. All four variants passed
locally; one targeted remote retry passed. The initial failure is
retained and its root cause is not established.

## Risks

Grok must keep checking the original request while admission is pending.
If that conversation ends, the durable request remains available for
reconciliation; Paperclip cannot infer provider idleness from a delay.
Prepared intents from older dispatchers are cancelled by the authority
guard. The real client must verify the polling path after deployment. No
schema migration is needed.

## Model Used

OpenAI GPT-6 in Codex, with reasoning, repository editing, shell tools
and computer use. The exact serving model ID, snapshot and context
window are not exposed by this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-15 07:10:35 -05:00
1 parent 6acec4266e
commit 7f9d3d1929
6 files changed
+60 -23

No files matched your search

+5 -2
View File
@@ -29,8 +29,11 @@ paste it into Paperclip's authorization page. Keep it out of Bot chat.
Assign ordinary tasks to the agent. Read its progress, answer its question cards,
and review its documents and files in the task thread. The Bot can initiate a
new task from its Grok conversation through the admitted work-session tools.
An immediate proactive claim does not send the same assignment back through the
routine.
The originating conversation checks the same request while admission is queued.
Neither immediate nor delayed Bot-initiated admission sends that assignment back
through the routine. Grok keeps the request ID stable until admission succeeds
or fails. Human-assigned work and continuations after an answered question still
use their normal assignment wakes.
## Files and completion
@@ -1,6 +1,6 @@
# Grok Bot production readiness
Status as of October 15: a real assigned task now reaches canonical completion. Qualification found an active-session wake hazard; its correction is implemented and passing local checks. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
Status as of October 15: real assigned and Bot-initiated tasks reach canonical completion. The active-session wake correction is merged. A real proactive test found a delayed-admission echo; its correction is under verification. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
## Current production finish line — October 14
@@ -12,12 +12,12 @@ There is no testing call, case-count, token or spending cap. The former six-case
| --- | --- | --- |
| Ordinary onboarding works | Actual Grok UI, fixed agent consent, saved setup, wrong-key behavior, authenticated challenge, refresh, reconnect and revocation | Resumed real Grok authorization, saved setup Done, authenticated routine challenge and automatic refresh passed on the October 15 serving revision. Runtime reconnect copy fix is merged; verify its published UI and fresh production setup next. |
| Assigned work reaches a usable result | One actual managed workflow: progress, question and answer, document, downloaded file/hash, canonical completion or review and cleanup | A preliminary comparison task on serving Core ff0 completed progress → question → answer → report/CSV → canonical Done, with both cleanup receipts. The cited CSV downloads with the exact publication hash. Two distinct CSV uploads accompanied an active-session result wake; correct that wake behavior and requalify the final candidate. |
| Bot initiates governed work | Real-client task creation/claim/completion with stable receipts and no outbound echo or duplicate effects | Unverified. Run after the assigned-work gate. |
| Bot initiates governed work | Real-client task creation/claim/completion with stable receipts and no outbound echo or duplicate effects | A real idle-conversation request created one task and one managed run, saved a 100-word report, reached accepted Done and cleaned up its lease. A delayed-admission wake and two acceptance attempts were also recorded. Functional completion passed; no-echo acceptance failed. Requalify the correction. |
| Recovery and authority are reliable | Healthy work past the prior five-minute failure window; actual controller restart preserving assignment/session/process and no duplicate wake; Stop, pause, reassignment, revocation, stale calls, queues and cleanup | The old hold was inspected and cleared. The successful comparison continuation ended below five minutes. Healthy work beyond that window, an actual restart and active Stop remain unverified; protocol tests are separate evidence. |
| One identifiable reviewed candidate passes source gates | Focused protocol/auth/Runner/UI tests, full CI/typecheck/tests/build/token/Storybook evidence and exact artifacts | The active-session wake correction follows origin/master. Initial local checks used d6e4b36c8c049b85813caf3e3cbf1f8d87e4e068; CI and review must verify the final PR head. Three regressions failed before the fix. Focused tests, repository typecheck/build, token gates and Storybook checks pass. Full CI, review and publication of this correction remain required; prior candidate receipts are historical. |
| One identifiable reviewed candidate passes source gates | Focused protocol/auth/Runner/UI tests, full CI/typecheck/tests/build/token/Storybook evidence and exact artifacts | Active-session wake correction #16394 merged with 55 passing checks, two optional skips and review 5/5. The proactive echo correction started from master 7fc2dc8b3236e1149ae32756b6153a503623404b and was rebased onto freshly fetched master 250429942e27d568f20d3bc7dddcfc84a666480a before submission. Two regression cases reproduce the real delayed-admission wake before the fix. All 214 focused protocol/auth/question checks pass, including 80 Grok and Dot checks. Repository typecheck/build, token gates, the 2,731-story structure and Storybook build pass. Fresh CI, review, publication and the real no-echo repeat remain required. |
| Production opt-in is delivered and smoke-tested | Staging acceptance, correct served Core/distribution/Runner, normal editable opt-in controls/defaults, actual production Bot workflow and inspectable task/output links | Cloud opt-in policy and release-guide corrections are merged. Staging Cloud provenance and minimal feature values were verified, with Dot disabled and defaults unchanged. The corrected wake candidate, remaining real-client cases, ordinary production opt-in controls and production smoke are outstanding. |
The critical path is to review and publish the active-session wake correction, verify its served identity, then complete the remaining real-client acceptance cases. The selected staging workspace remains the test destination. Preserve one source manifest per case; do not combine incompatible runs into one green release.
The critical path is to review and publish the proactive-admission echo correction, verify its served identity, then complete the remaining real-client acceptance cases. The selected staging workspace remains the test destination. Preserve one source manifest per case; do not combine incompatible runs into one green release.
Real Grok reconnect restored the same agent, plugin and webhook-only routine. Saved setup reached Done after both delivery and authenticated MCP acknowledgement. After the initial access token expired, Grok refreshed it and acknowledged another challenge without reauthorization. Grok/MCP/Runner were enabled with Dot disabled; setup allocated no managed Runner. The operator used the existing saved-invitation path to get the secure pairing-code copy control before the Runtime copy correction published. Record that as assisted recovery, not proof of unassisted fresh onboarding. The copy correction is merged in #16373 after 55 passing CI checks, two optional skips and review 5/5.
@@ -27,7 +27,11 @@ An unread asynchronous tool result also caused a webhook during accepted work. T
Core d73d7a8651db0897cb50adbca42731bcb8aed550 and its canonical private Cloud composition passed publication. One single-workspace request passed preparation but was blocked by an existing shared rollout; it created no campaign. The existing rollout later released the reservation. That prepared source is now superseded by the wake correction. Keep its receipts as history and do not resume its older deployment request. Select a new immutable reviewed source after this fix passes CI and review. Production opt-in policy and release-guide changes are merged, but production rollout and smoke remain outstanding.
Desktop control stopped responding after the successful exchanges. Resetting the tool did not restore it, and the user has been asked to reopen Codex. Browser control and webhook/MCP work remain usable; native Bot-initiated prompts and fresh production setup require restored desktop control or ordinary human setup actions. The dedicated production workspace has no qualification agent grant or smoke task.
Desktop control recovered on October 15. One ordinary message in the actual Grok conversation initiated the proactive source investigation; the operator created no preliminary task and sent no extra nudge. Serving Core 11d69d04c7531cf7eb37f7994b7112797e145837 includes #16394. The Bot created one task and one run, saved a 100-word report, submitted an accepted canonical result and reached Done. Its managed lease has a successful cleanup receipt. The exact public PR marker matches the saved control value, was absent from the operator prompt and appears in the saved report with its source. No Paperclip proxy operation occurred. The report records native GitHub API use; a complete provider tool transcript is unavailable.
The proactive run also received an authenticated delayed-admission webhook while its original conversation was waiting. Two acceptance requests and two title writes were recorded. This fails the no-echo release condition despite successful task completion. Two local regressions reproduce the extra webhook for a delayed offer and a prepared intent recovered after restart. The correction keeps admission in the original caller's polling path and rejects old prepared proactive wakes. Human assignments and question continuations retain their ordinary dispatch paths. Source and real-client requalification are required.
Normal staging automation has advanced beyond the older prepared candidate. No shared CI routing switch was changed. The earlier temporary fallback request is no longer needed for the already served successor. The dedicated production workspace still has no qualification agent grant or smoke task.
Keep native providers and person-purpose MCP connections unchanged. No browser automation belongs in normal operation. Defer localhost relays, internal Grok APIs, marketplace distribution and Paperclip-connected third-party tools. Completion of a Paperclip work session does not assert remote Grok termination.
+3
View File
@@ -243,6 +243,9 @@ describe("durable Dot Runner integration", () => {
try {
const requests = await Promise.all([1, 2, 3].map(() => f.broker.requestTurn(f.principal, "Create hello and assign it to my owner", requestId)));
expect(requests[1]).toEqual(requests[0]); expect(requests[2]).toEqual(requests[0]);
expect(requests[0]!.instruction).toContain(provider === "grok_bot"
? "Queued admission does not start another Grok routine"
: "Events also notify queued work");
const intake = await db.select().from(issues).where(eq(issues.id, requests[0]!.issueId));
expect(intake).toHaveLength(1);
expect(intake[0]).toMatchObject({ assigneeAgentId: f.agent.id, createdByAgentId: f.agent.id, responsibleUserId: f.userId });
@@ -289,6 +289,28 @@ describe("durable Grok routine qualification", () => {
expect((await dotRunnerBroker(db).read(f.principal, work.id)).toolUsageInstructions).toContain("paperclip_grok_operation_status");
});
it.each(["new offer", "prepared before restart"] as const)("does not echo delayed Bot-initiated work: %s", async scenario => {
const f = await fixture(); const { work, run } = await admittedWork(f, "offered");
await db.update(heartbeatRuns).set({ contextSnapshot: { grokProactiveRequest: {
requestId: randomUUID(), bindingId: f.pairing.bindingId, bindingGeneration: 1,
} } }).where(eq(heartbeatRuns.id, run.id));
await db.update(dotRunnerAssignments).set({ createdAt: new Date(Date.now() - 60_000) }).where(eq(dotRunnerAssignments.id, work.id));
const legacy = scenario === "prepared before restart" ? await f.service.reserve({
companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId,
assignmentId: work.id, kind: "mailbox_updated", sourceEventId: `proactive-admission:${work.id}`,
}) : null;
f.delivery.mockClear(); const dispatcher = createGrokRoutineDispatcher(db, () => true, f.delivery);
await dispatcher.tick(); await dispatcher.tick();
expect(f.delivery).not.toHaveBeenCalled();
const wakes = await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.assignmentId, work.id));
if (legacy) expect(wakes).toEqual([expect.objectContaining({ id: legacy.id, status: "cancelled" })]);
else expect(wakes).toEqual([]);
// The original conversation still owns and can accept the polled offer.
await expect(dotRunnerBroker(db).read(f.principal, work.id)).resolves.toBeDefined();
await (await acceptance(f, work))();
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, work.id)))[0]?.status).toBe("accepted");
});
it("cancels a queued wake when its assignment was already accepted", async () => {
const f = await fixture(); const { work } = await admittedWork(f, "offered");
const queued = await f.service.reserve({ companyId: f.company.id, agentId: f.agent.id,
+16 -3
View File
@@ -249,6 +249,16 @@ function createBroker(db: Db) {
if (options.requireOffered && authority.assignment.status !== "offered") {
throw fail("This work was already accepted. Read its feedback and operation receipts without another routine wake.");
}
if (options.requireOffered && authority.binding.providerKind === "grok_bot") {
// A prepared intent from an older dispatcher must not start a second
// interaction while the original caller is waiting for admission.
const [run] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, authority.assignment.runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
const proactive = run?.contextSnapshot?.grokProactiveRequest as Record<string, unknown> | undefined;
if (proactive?.bindingId === authority.binding.id && proactive.bindingGeneration === authority.binding.generation) {
throw fail("This Bot requested the work. Continue polling its original request instead of starting another routine.");
}
}
return authority;
},
async authorizeBinding(principal: McpPrincipal, companyId: string, bindingId: string) {
@@ -390,13 +400,16 @@ function createBroker(db: Db) {
if (!assignment) await new Promise(resolve => setTimeout(resolve, 50));
} while (!assignment && Date.now() < deadline);
return { ...wake, issueId: task.id, identifier: task.identifier, assignment: assignment ?? null,
instruction: wake.status === "admission_failed" ? wake.message : "Drain the inbox now. If assignment is present, read and accept it, then use its catalog. If admission is still pending, retry this provider’s request_turn tool with exactly the same requestId and prompt to check this intake; never create a second request. Events also notify queued work. This intake supplies normal run authority; no human needs to create a preliminary task." };
instruction: wake.status === "admission_failed" ? wake.message : `Drain the inbox now. If assignment is present, read and accept it, then use its catalog. If admission is still pending, retry this provider’s request_turn tool with exactly the same requestId and prompt to check this intake; never create a second request. ${b.providerKind === "grok_bot" ? "Queued admission does not start another Grok routine. Keep checking this request from the current conversation until it is admitted or fails." : "Events also notify queued work."} This intake supplies normal run authority; no human needs to create a preliminary task.` };
},
async requestWork(principal: McpPrincipal, issueId: string, requestId: string) {
const b = await principalBinding(principal);
if (!z.uuid().safeParse(requestId).success) throw fail("Use a stable UUID requestId.");
if (!await enabled(b.providerKind) || !(await this.bindingForAgent(b.companyId, b.agentId))?.wakeVerified) throw fail("External agent admission is unavailable.");
const key = `dot-work:${b.id}:${b.generation}:${requestId}`;
const admissionMessage = b.providerKind === "grok_bot"
? "Normal admission determines when this task can run. Keep checking the original request with the same request ID and arguments, then read and accept its inbox assignment. Queued admission does not start another Grok routine."
: "Normal admission determines when this task can run. Read the mailbox after its event.";
const receipt = async () => (await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, b.companyId), eq(agentWakeupRequests.agentId, b.agentId), eq(agentWakeupRequests.idempotencyKey, key))).limit(1))[0];
const replay = async (row: typeof agentWakeupRequests.$inferSelect) => {
if (row.payload?.issueId !== issueId) throw fail("requestId was reused for another task.");
@@ -407,7 +420,7 @@ function createBroker(db: Db) {
return { status: "admission_failed", runId: row.runId, admissionStatus: run.status,
message: "The previous admission ended without an active assignment. Stop waiting for its mailbox event. Inspect the task's run and use normal task recovery after resolving the failure. This request ID retains its original receipt." };
}
return { status: "requested", runId: row.runId, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
return { status: "requested", runId: row.runId, message: admissionMessage };
};
// A retry is a receipt read, including after the task yielded or finished.
const old = await receipt();
@@ -424,7 +437,7 @@ function createBroker(db: Db) {
payload: { issueId, dotRequestId: requestId }, contextSnapshot: { issueId, ...(b.providerKind === "grok_bot" ? { grokProactiveRequest: { requestId, bindingId: b.id, bindingGeneration: b.generation } } : {}) }, idempotencyKey: key, requestedByActorType: "agent", requestedByActorId: b.agentId,
allowRunCoalescing: false, durableDotRequest: { id: receiptId, companyId: b.companyId, agentId: b.agentId, issueId, requestId, idempotencyKey: key, requestedAt: new Date() } });
const reserved = await receipt();
return reserved ? replay(reserved) : { status: "requested", runId: run?.id ?? null, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
return reserved ? replay(reserved) : { status: "requested", runId: run?.id ?? null, message: admissionMessage };
} catch (error) {
// A concurrent reservation can lose the PK race on a different task.
// Replay only that durable receipt; do not mask unrelated failures.
+5 -13
View File
@@ -1,5 +1,5 @@
import { and, asc, eq, isNull, lt, sql } from "drizzle-orm";
import { grokBotDispatches, grokBotRoutines, dotAgentBindings, dotRunnerAssignments, heartbeatRuns, type Db } from "@paperclipai/db";
import { and, asc, eq } from "drizzle-orm";
import { grokBotDispatches, grokBotRoutines, type Db } from "@paperclipai/db";
import { grokRoutineService } from "./routine.js";
import type { deliverGrokRoutineWake } from "./webhook.js";
import { externalBotEnabled } from "../external-bot-authority.js";
@@ -14,17 +14,9 @@ export function createGrokRoutineDispatcher(db: Db, isBackgroundWorkEnabled: ()
running = true;
try {
const service = grokRoutineService(db, deliver);
// A proactive caller receives an immediate assignment without an echo.
// Queued admission gets one delayed wake only while still unaccepted.
const proactive = await db.select({ assignment: dotRunnerAssignments }).from(dotRunnerAssignments)
.innerJoin(dotAgentBindings, eq(dotAgentBindings.id, dotRunnerAssignments.bindingId))
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, dotRunnerAssignments.runId))
.where(and(eq(dotAgentBindings.providerKind, "grok_bot"), isNull(dotAgentBindings.revokedAt), eq(dotRunnerAssignments.status, "offered"),
lt(dotRunnerAssignments.createdAt, new Date(Date.now() - 10_000)), sql`${heartbeatRuns.contextSnapshot}->'grokProactiveRequest'->>'bindingId' = ${dotAgentBindings.id}::text`)).limit(10);
for (const { assignment } of proactive) {
try { await service.reserve({ companyId: assignment.companyId, agentId: assignment.agentId, bindingId: assignment.bindingId,
assignmentId: assignment.id, kind: "mailbox_updated", sourceEventId: `proactive-admission:${assignment.id}` }); } catch { /* Current authority is rechecked on reservation and send. */ }
}
// Bot-initiated callers poll their durable request while admission is
// pending. Delay does not prove that the originating conversation ended.
// Starting a webhook here would create a competing Grok interaction.
// An accepted session already has a routine handling it. Quiet MCP
// traffic does not prove that routine ended. Keep feedback and receipts
// durable for the Bot to read instead of starting overlapping work.