From 3b80594f37c2c2917ac9cdecc0772a94ef532686 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 00:59:48 -0500 Subject: [PATCH] fix(server): connect governed response lifecycle notifications Co-Authored-By: Paperclip --- ...2026-10-24-recurring-server-sleep-stack.md | 9 ++++ ...26-governed-review-response-sleep-audit.md | 47 +++++++++++++++++++ server/src/services/agent-lifecycle.ts | 2 + .../chat-run-milestone-notifications.ts | 3 ++ .../native-finalization-notifications.ts | 4 +- .../external-chat-wait.integration.test.ts | 25 ++++++++++ .../status-decision-committer.ts | 2 +- server/src/services/tool-gateway.ts | 2 +- 8 files changed, 91 insertions(+), 3 deletions(-) diff --git a/doc/plans/2026-10-24-recurring-server-sleep-stack.md b/doc/plans/2026-10-24-recurring-server-sleep-stack.md index 06e662f0b46..71bb42dc887 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -2391,3 +2391,12 @@ reproduced missed-wake failures and repaired test stub. Interaction producer coverage, discovery cost, startup wiring, periodic removal and full acceptance remain outstanding. No slice 49 PR or whole-instance sleep is claimed. Earlier stack gates, master reconciliation and every remaining original duty stay in scope. + +Slice 49 lifecycle checkpoint: the real agent-resume missed wake was reproduced +and repaired through the existing lifecycle hook. Milestone/finalization scopes +now feed review recovery, and pending-effect settlement supplies its task identity. +The 172-case owner suite and 162 lifecycle/milestone/finalization consumer cases +pass, with server types passing. The owner audit lists remaining interaction, task +authority and coordinator producer evidence. Registration, periodic removal, query +cost, full verification and publication remain outstanding; the full stack scope +and older unresolved gates remain unchanged. diff --git a/doc/plans/2026-10-26-governed-review-response-sleep-audit.md b/doc/plans/2026-10-26-governed-review-response-sleep-audit.md index af68ba9e7b9..ad6a147a224 100644 --- a/doc/plans/2026-10-26-governed-review-response-sleep-audit.md +++ b/doc/plans/2026-10-26-governed-review-response-sleep-audit.md @@ -194,3 +194,50 @@ and confirmation kinds. Payload-only updates that leave a competing card pending do not reactivate a response. Task ownership changes already carry task invalidation, but direct cleanup writers must be individually accounted for. The polling backstop is retained until this audit and the remaining acceptance requirements are complete. + +## Lifecycle and finalization notification checkpoint + +A real agent-resume regression failed before wiring and passes afterward. The +existing lifecycle pre-status-write hook now signals this worker when an agent +becomes invokable. Existing task/run milestones and finalization writes also +fan out to the new topic. The finalization scope accepts an optional issueId so +issue-wide pending-effect settlement can target its affected task instead of +reloading all governed responses in the company. Existing pending-effect delivery +retains its previous topic semantics. The tool gateway's separate approval link +insertion now supplies its known issue identity. + +All 172 owner cases pass, plus 58 agent lifecycle, 72 milestone and 32 native +finalization consumer cases. Server typecheck passes. Evidence is in +/tmp/sleep49-agent-before.log (reproduced failure), +/tmp/sleep49-lifecycle-full.log, /tmp/sleep49-lifecycle-types.log, +/tmp/sleep49-agent-consumers.log, /tmp/sleep49-milestone-consumers.log, +/tmp/sleep49-finalization-consumers.log and their status files. + +Further producer audit findings, not completed migration claims: + +- Main interaction service: suggest-task accept/reject, item-verdict completion, + connection-intent resolve, administrative cancel/withdraw/skip and comment + supersession can release a competing pending interaction. Several existing + milestone hooks cover only ask_user_questions/request_confirmation. Checkbox + confirmation resolution and other kinds need their own governed-response wake + without unnecessarily broadening unrelated workers. Some item-verdict comment + expiry writes do not currently own a transaction. +- Direct interaction writers: satisfied-connection cleanup and OAuth confirmation + acceptance/denial need coverage. OAuth connection-intent retry-phase changes + leave the interaction pending and cannot by themselves restore presentation. + Dismissal also leaves status pending; confirm the owning predicate before + introducing a wake for it. +- Existing agent-conversation reset, tool-action review, obsolete policy review, + automatic completion review and native decision commits already emit task + milestones/finalization signals. Verify every alternate branch before marking + those writer groups covered. +- Issue service currently signals response availability on task status/assignment + changes, but that boundary only targets GitHub response queues. Governed-response + task status/assignment/execution-state restoration still needs explicit coverage. +- Coordinator claim/retry notifications now reach the worker, but add a real-store + release test; synthetic late claim/deadline owner tests alone do not prove that + producer path. Current authority registration and all publication transitions + also remain part of the producer audit. + +The worker remains unregistered and periodic repair is unchanged. These commits +are checkpoints, not PR readiness or whole-instance sleep acceptance. diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts index 246c5e170d5..62c352a5112 100644 --- a/server/src/services/agent-lifecycle.ts +++ b/server/src/services/agent-lifecycle.ts @@ -1,3 +1,4 @@ +import { notifyNativeReviewResponse } from "./native-review-response-notifications.js"; import { isAgentStatusInvokable } from "@paperclipai/shared"; import { notifyNativeResponsePresentation } from "./native-response-presentation-notifications.js"; import { logger } from "../middleware/logger.js"; @@ -24,6 +25,7 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li async beforeStatusChange(tx, companyId, agentId, before, after) { if (!isAgentStatusInvokable(before) && isAgentStatusInvokable(after)) { await notifyNativeResponsePresentation(tx, { companyId, agentId }); + await notifyNativeReviewResponse(tx, { companyId, agentId }); } }, reportFailure(context, error) { diff --git a/server/src/services/chat-run-milestone-notifications.ts b/server/src/services/chat-run-milestone-notifications.ts index 00cb9f0414a..21dff5c33e2 100644 --- a/server/src/services/chat-run-milestone-notifications.ts +++ b/server/src/services/chat-run-milestone-notifications.ts @@ -1,3 +1,4 @@ +import { notifyNativeReviewResponse } from "./native-review-response-notifications.js"; import { notifyNativeResponsePresentation } from "./native-response-presentation-notifications.js"; import { notifyChatConfirmationWork } from "./chat-confirmation-notifications.js"; import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js"; @@ -9,6 +10,8 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio export type ChatRunMilestoneScope = { companyId: string; issueId?: string; runId?: string }; export async function notifyChatRunMilestones(tx: object, scope: ChatRunMilestoneScope) { + await notifyNativeReviewResponse(tx, { companyId: scope.companyId, issueId: scope.issueId, + runId: scope.issueId ? undefined : scope.runId }); await notifyNativeResponsePresentation(tx, { companyId: scope.companyId, issueId: scope.issueId, runId: scope.issueId ? undefined : scope.runId }); await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubReviewChecks, JSON.stringify({ companyId: scope.companyId })); diff --git a/server/src/services/native-finalization-notifications.ts b/server/src/services/native-finalization-notifications.ts index d1441e7ba5d..9f6a1dfe27f 100644 --- a/server/src/services/native-finalization-notifications.ts +++ b/server/src/services/native-finalization-notifications.ts @@ -1,10 +1,12 @@ +import { notifyNativeReviewResponse } from "./native-review-response-notifications.js"; import { notifyNativeResponsePresentation } from "./native-response-presentation-notifications.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; -export type NativeFinalizationWorkScope = { companyId: string; runId?: string }; +export type NativeFinalizationWorkScope = { companyId: string; runId?: string; issueId?: string }; export async function notifyNativeFinalizationWork(transaction: object, scope: NativeFinalizationWorkScope) { await notifyNativeResponsePresentation(transaction, scope); + await notifyNativeReviewResponse(transaction, scope); await notifyDeliveryWork(transaction, DELIVERY_QUEUES.nativePendingEffects, JSON.stringify({ companyId: scope.companyId, runId: scope.runId })); } diff --git a/server/src/services/native-runtime/external-chat-wait.integration.test.ts b/server/src/services/native-runtime/external-chat-wait.integration.test.ts index 7b830a5bebe..69bd158e5eb 100644 --- a/server/src/services/native-runtime/external-chat-wait.integration.test.ts +++ b/server/src/services/native-runtime/external-chat-wait.integration.test.ts @@ -48,6 +48,7 @@ import type { import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { finalizeNativeRun, recoverCommittedNativeReviewResponse } from "./native-run-finalizer.js"; +import { configureAgentLifecycle, createAgentLifecycle, processAgentLifecycle } from "../agent-lifecycle.js"; import { approvalRecords } from "../approvals.js"; import { issueApprovalService } from "../issue-approvals.js"; import { nativeReviewResponseWork } from "./native-review-response-work.js"; @@ -2937,6 +2938,30 @@ describe("native external-chat response wait", () => { } finally { await coordinator.stop(); } }); + it("wakes dormant governed response recovery after real agent resume", async () => { + const fixture = await seedRecoverableReviewResponse(); + await db.update(agents).set({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["manual"], lifecycleOperation: null }) + .where(eq(agents.id, fixture.agentId)); + const errors: unknown[] = []; + const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) }); + const worker = nativeReviewResponseWork(db).register(coordinator, () => true); + const lifecycle = configureAgentLifecycle(db, { + requiredPluginIds: async () => [], runHost: async () => "complete", runPlugin: async () => "complete", + }); + try { + await worker.ready; + await expect.poll(() => ({ active: idleWorkSnapshot().active, next: coordinator.nextWakeAt() }), + { timeout: 10_000 }).toEqual({ active: 0, next: null }); + expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, fixture.runId))).toHaveLength(0); + await createAgentLifecycle(db).resumeAgent(fixture.agentId); + await processAgentLifecycle(db, fixture.agentId); + await expect.poll(async () => (await db.select().from(agents).where(eq(agents.id, fixture.agentId)))[0].status).toBe("idle"); + await expect.poll(async () => (await db.select().from(issueComments).where(eq(issueComments.createdByRunId, fixture.runId))).length, + { timeout: 5_000 }).toBe(1); + expect(errors).toEqual([]); + } finally { await lifecycle.stop(); await coordinator.stop(); } + }); + it("reconstructs scheduled governed responses at startup and leaves their history quiet", async () => { const fixture = await seedRecoverableReviewResponse(); const errors: unknown[] = []; diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 10a59a0a2aa..92002eed72b 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -1238,7 +1238,7 @@ async function materializeDecisionEffect(input: { ) .for("update"); if (pending.length === 0) throw new Error("native_pending_effect_missing"); - await notifyNativeFinalizationWork(input.tx, { companyId: input.companyId }); + await notifyNativeFinalizationWork(input.tx, { companyId: input.companyId, issueId: input.issue.id }); await input.tx .update(statusDecisionEffects) .set({ diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 393192f4bdc..12f28c53af2 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -2617,7 +2617,7 @@ export function createToolGatewayService( approvalId: approval.id, linkedByAgentId: input.session.agentId, }) - .onConflictDoNothing()); + .onConflictDoNothing(), { issueIds: [approvalIssueId] }); } const interaction = await interactions.create(