fix(server): connect governed response lifecycle notifications

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 00:59:48 -05:00
1 parent 53b26e24f9
commit 3b80594f37
8 files changed
+91 -3

No files matched your search

@@ -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.
@@ -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.
+2
View File
@@ -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) {
@@ -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 }));
@@ -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 }));
}
@@ -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[] = [];
@@ -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({
+1 -1
View File
@@ -2617,7 +2617,7 @@ export function createToolGatewayService(
approvalId: approval.id,
linkedByAgentId: input.session.agentId,
})
.onConflictDoNothing());
.onConflictDoNothing(), { issueIds: [approvalIssueId] });
}
const interaction = await interactions.create(