diff --git a/doc/grok-bot-agent.md b/doc/grok-bot-agent.md index 952fbace5a0..ebea2c20524 100644 --- a/doc/grok-bot-agent.md +++ b/doc/grok-bot-agent.md @@ -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 diff --git a/doc/plans/2026-10-13-grok-bot-production-readiness.md b/doc/plans/2026-10-13-grok-bot-production-readiness.md index e5c3697dfaf..05de9675f3e 100644 --- a/doc/plans/2026-10-13-grok-bot-production-readiness.md +++ b/doc/plans/2026-10-13-grok-bot-production-readiness.md @@ -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. diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index 80ce58575ee..5e4db08a3b5 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -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 }); diff --git a/server/src/__tests__/grok-bot-routine.test.ts b/server/src/__tests__/grok-bot-routine.test.ts index 54ed20e5473..e54a3f286f2 100644 --- a/server/src/__tests__/grok-bot-routine.test.ts +++ b/server/src/__tests__/grok-bot-routine.test.ts @@ -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, diff --git a/server/src/services/dot-runner-broker.ts b/server/src/services/dot-runner-broker.ts index 0a41d9d78cd..c602ee37a3e 100644 --- a/server/src/services/dot-runner-broker.ts +++ b/server/src/services/dot-runner-broker.ts @@ -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 | 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. diff --git a/server/src/services/grok-bot/dispatcher.ts b/server/src/services/grok-bot/dispatcher.ts index e9f08cb2e8b..864e3707114 100644 --- a/server/src/services/grok-bot/dispatcher.ts +++ b/server/src/services/grok-bot/dispatcher.ts @@ -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.