Files
PaperClipAI/server/src/services/native-runtime/native-workspace-finalization-ownership.ts
T
DottaandClaude Opus 5.5 512fbea0bb fix(native): deliver messages saved while a native run finalizes (#19183)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user can reply on a task while the agent's run is still finishing
> - A remote native run first commits its result and its status decision
(for example `done`). Then its sandbox environment settles (harness
backup, warm retention).
> - A message that arrives after the final turn took its input is saved
as a deferred receipt behind that run
> - The saved-message recovery path holds the receipt while the source
run still owns its environment lease or its workspace copy-back owner
> - Nothing notified that path when the lease or the owner settled, so
the messages waited for an unrelated company event
> - This pull request notifies the existing deferred-receipt work when a
run's environment lease settles or its workspace finalization owner is
released
> - The benefit is that messages sent while a run finalizes are
delivered once, soon after the run settles, and a task closed in that
window is reopened for them

## Linked Issues or Issue Description

**What happened?**
A user posted three plain comments on a task while the agent's remote
native run was finalizing. The final turn had already called its finish
tool. The comments were saved in one deferred receipt. The run then
committed `done` and its status became `succeeded`. No follow-up run
started. The task stayed `done` with three unanswered requests. About 20
minutes later, an unrelated event in the same company woke the
deferred-receipt worker, and the messages were delivered.

**Expected behavior**
A message that arrives after the last turn took its input, but before
the run releases, gets one follow-up turn as soon as the run settles. If
finalization marked the task `done` in that window, the task is reopened
for the message. Messages that the finished turn already consumed are
not replayed.

**Steps to reproduce**
1. Start a native run with a remote (sandbox) environment.
2. After the agent calls its finish tool, post a user comment on the
task.
3. Let finalization commit `done` and project the run as `succeeded`.
4. Observe that the comment's deferred receipt stays
`deferred_issue_execution` after the environment lease settles.

**Root cause**
A remote native run is finished by native finalization, which projects
the terminal status. The in-process run completion
(`releaseIssueExecutionAndPromote`) does not run for it. Its saved
messages depend on the stranded-queue sweep in
`resumeDeferredExecutionReceipts`, which calls the release with
`resumeStrandedQueue`. That release correctly waits while
`hasPendingRunCleanup` is true (an unsettled environment lease or a
workspace finalization owner). Finalization notifies the
deferred-receipt worker when it projects the run, but the cleanup is
still pending at that time. The worker then drops the entry. Lease
settlement and owner release did not notify the worker again.

Refs #19122 (saves a redirect sent while a stopped run settles; this
change reuses the same deferred-receipt delivery path for normally
completing runs).

## What Changed

- `environmentService.releaseLease` notifies deferred-execution work for
the company when a run's lease leaves `active` or `pending_cleanup`
(released, retained, or failed).
- `environmentService.releaseLeasesForRun` notifies deferred-execution
work in the same way.
- `withNativeWorkspaceFinalizationOwnership` notifies deferred-execution
work when it releases its owner.
- Delivery uses the existing path: `resumeDeferredExecutionReceipts` →
stranded-queue release → deferred wake promotion (with the existing
reopen rule for user messages). No new delivery mechanism.
- New regression in `deferred-execution-receipt-work.test.ts` with the
real deferred-receipt worker, the real release adapter, and the real
lease service.
- `doc/execution-semantics.md`: one additive paragraph for messages sent
while a run finalizes.

## Verification

- Staging evidence (read-only): the task's saved queue held the three
comments as one `deferred_issue_execution` receipt after the run
succeeded. The run had no `run succeeded` lifecycle event (it was
finished by native finalization, not run completion). The receipt was
promoted about 20 minutes later with `reopenedFrom: done` by the same
recovery path.
- Red before the fix: the new test failed with `expected null to be
truthy` (the receipt stayed deferred with no run after the lease
settled).
- Green after the fix: `cd server && npx vitest run
src/__tests__/deferred-execution-receipt-work.test.ts -t "finishing
native run"`.
- Safety cases in the new test:
- The receipt stays deferred while the environment lease is active
(cleanup holds still apply).
- After settlement, exactly one run carries both messages in order, and
the done task is reopened once.
  - A second settlement signal does not deliver the messages again.
- Related suites: `deferred-execution-receipt-work`,
`environment-service`, `environment-cleanup-work`,
`native-environment-settlement-work`, `idle-sleep-safety`.
- Not done: a live staging reproduction at the fixed revision.
Deployment is out of scope.

## Risks

- The deferred-receipt worker reloads the company's deferred receipts
after each run lease settlement. Each reload is bounded and runs only
when deferred receipts exist. The notification is transactional, so a
rolled-back release does not wake the worker.
- Low risk: no schema change, no API change, and no change to admission
or reopen rules.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, browser for read-only staging diagnostics).

## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:26:42 -06:00

324 lines
19 KiB
TypeScript

import { notifyNativeResultCommit } from "../native-result-commit-notifications.js";
import { notifyNativeWorkspaceFinalization, releaseNativeWorkspaceFinalization } from "../native-workspace-finalization-notifications.js";
import { notifyNativeStatusReassessment } from "../native-status-reassessment-notifications.js";
import { notifyQueuedRunDispatch } from "../run-dispatch-notifications.js";
import { notifyDeferredExecutionWork } from "../deferred-execution-notifications.js";
import { beginRunPhase } from "../execution-preparation-timing.js";
import { logActivity } from "../activity-log.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { nativeSha256 } from "./canonical.js";
import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js";
import { randomUUID } from "node:crypto";
import os from "node:os";
import { and, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, workspaceOperations, withDedicatedDbConnection, type Db } from "@paperclipai/db";
import { currentNativeWorkspaceRecoveryResult } from "./native-workspace-recovery-binding.js";
import { withWorkspaceLockKeepalive } from "./native-workspace-lock-keepalive.js";
import {
currentNativeControllerIdentity,
evaluateNativeControllerTakeover,
} from "./native-restart-recovery.js";
import { currentWorkspaceWorker, requestWorkspaceWorkerStopProof, type WorkspaceWorker, type WorkspaceWorkerStopProof } from "./native-workspace-platform-proof.js";
const OWNER_KEY = "nativeWorkspaceFinalizationOwner";
export function nativeWorkspaceFinalizationLockKey(companyId: string, runId: string): string {
return `native-workspace-finalization:${companyId}:${runId}`;
}
export async function tryLockNativeWorkspaceFinalization(tx: Db, input: { companyId: string; runId: string }): Promise<boolean> {
const [lock] = await tx.execute(sql`select pg_try_advisory_xact_lock(hashtextextended(
${nativeWorkspaceFinalizationLockKey(input.companyId, input.runId)}, 0)) as acquired`) as unknown as Array<{ acquired: boolean }>;
return lock.acquired;
}
const activeOwnerTokens = new Set<string>();
const activeRunOwners = new Map<string, { token: string; db: Db }>();
// Positive same-boot evidence, retained only when durable cleanup could not be
// confirmed. Absence from the active set alone never proves another token joined.
const joinedOwnerTokens = new Set<string>();
type Owner = {
token: string;
controllerBootId?: string;
hostname: string;
pid: number;
processStartedAt: string;
worker?: WorkspaceWorker;
};
export type NativeWorkspaceFinalizationWaitReason =
| "busy"
| "owner_active"
| "platform_stop_pending"
| "owner_unverified"
| "binding_changed";
export type NativeWorkspaceFinalizationOwnership = {
token: string;
/** Recheck both the physical lock connection and the durable owner before publishing. */
assertHeld(): Promise<void>;
};
export class NativeWorkspaceFinalizationBusyError extends Error {
constructor() { super("native_workspace_finalization_busy"); }
}
export class NativeWorkspaceFinalizationOwnershipLostError extends Error {
constructor(options?: ErrorOptions) {
super("native_workspace_finalization_ownership_lost", options);
}
}
function readOwner(value: unknown): Owner | null {
if (!value || typeof value !== "object") return null;
const owner = value as Partial<Owner>;
return typeof owner.token === "string" && typeof owner.hostname === "string"
&& Number.isInteger(owner.pid) && Number(owner.pid) > 0
&& typeof owner.processStartedAt === "string" && Number.isFinite(Date.parse(owner.processStartedAt))
? owner as Owner : null;
}
/**
* The advisory lock serializes controllers without holding row locks across I/O.
* The durable receipt additionally prevents a disconnected lock session from
* authorizing takeover while its controller is still doing physical copyback.
* There is deliberately no wall-clock timeout that can steal a slow export.
*/
export async function withNativeWorkspaceFinalizationOwnership<T>(
input: { db: Db; companyId: string; runId: string; retainedRepairActionId?: string },
action: (ownership: NativeWorkspaceFinalizationOwnership) => Promise<T>,
): Promise<{ acquired: false; reason: NativeWorkspaceFinalizationWaitReason } | { acquired: true; value: T }> {
const identity = await currentNativeControllerIdentity();
const owner: Owner = {
token: randomUUID(), controllerBootId: identity.bootId, hostname: os.hostname(), pid: identity.pid,
processStartedAt: identity.processStartedAt.toISOString(), worker: currentWorkspaceWorker(),
};
const scope = and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
input.retainedRepairActionId ? inArray(heartbeatRuns.runtimeMode, ["native", "legacy"]) : eq(heartbeatRuns.runtimeMode, "native"));
const owned = and(scope, sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${owner.token}`);
const runKey = nativeWorkspaceFinalizationLockKey(input.companyId, input.runId);
let claimed = false;
let actionPromise: Promise<T> | undefined;
const finishAcquisition = beginRunPhase("execution.workspace_ownership_wait");
try {
return await withDedicatedDbConnection(input.db, (dedicated) => dedicated.transaction(async (lock) => {
const rows = await lock.execute(sql`select pg_try_advisory_xact_lock(hashtextextended(${nativeWorkspaceFinalizationLockKey(input.companyId, input.runId)}, 0)) as acquired,
(select setting::bigint from pg_settings where name = 'idle_in_transaction_session_timeout') as idle_timeout_ms`);
if (rows[0]?.acquired !== true) return {
acquired: false,
reason: !input.retainedRepairActionId && activeRunOwners.get(runKey)?.db === input.db ? "owner_active" : "busy",
} as const;
return withWorkspaceLockKeepalive(lock as unknown as Db, Number(rows[0].idle_timeout_ms), async () => {
// Reads and durable progress use the ordinary pool, not the lock transaction.
const [run] = await input.db.select({ profile: heartbeatRuns.runnerProfileJson,
issueId: sql<string | null>`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`,
agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(scope).limit(1);
if (!run) throw new Error("native_workspace_finalization_binding_missing");
const rawPrior = run.profile?.[OWNER_KEY];
const prior = readOwner(rawPrior);
let automaticResultId: string | undefined;
const currentRecoveryResult = async (db: Db) => {
if (!run.issueId) return null;
// Use the arbiter's lock order and release row locks before copyback I/O.
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId),
eq(nativeRunFinalizations.issueId, run.issueId),
)).for("update");
const [issue] = await db.select().from(issues).where(and(
eq(issues.companyId, input.companyId), eq(issues.id, run.issueId),
)).for("update");
const [currentRun] = await db.select().from(heartbeatRuns).where(scope).for("update");
if (input.retainedRepairActionId) {
const [repair] = await db.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, run.issueId),
eq(issueRecoveryActions.id, input.retainedRepairActionId),
)).for("update");
const continuation = repair?.evidence.explicitUserContinuation as {
runId?: string; readiness?: { mode?: string; sourceRunId?: string };
} | undefined;
const independentRun = continuation?.readiness?.mode === "fresh_isolated_workspace"
&& continuation.readiness.sourceRunId === input.runId ? continuation.runId : undefined;
return currentRun && issue && repair && repair.evidence.runId === currentRun.id
&& repair.evidence.workspaceRecovery && !coordinator?.leaseOwner
&& ["failed", "interrupted", "timed_out", "cancelled", "succeeded"].includes(currentRun.status)
&& (!issue.executionRunId || issue.executionRunId === independentRun)
&& (!issue.checkoutRunId || issue.checkoutRunId === independentRun) ? repair : null;
}
return coordinator && issue && currentRun ? currentNativeWorkspaceRecoveryResult(db, {
companyId: input.companyId, coordinator, issue, run: currentRun,
}) : null;
};
let platformProof: WorkspaceWorkerStopProof | null = null;
let platformStopPending = false;
if (rawPrior != null) {
const requireStopVerification = async () => {
if (input.retainedRepairActionId) return { acquired: false, reason: "owner_unverified" } as const;
const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior });
const nextAction = "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.";
const recovery = issueRecoveryActionService(input.db);
const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null;
// Rechecking the same physical owner is not a new recovery attempt.
if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) {
if (!existing.evidence?.workspaceOwnerStop) {
await input.db.update(issueRecoveryActions).set({ nextAction, updatedAt: new Date(),
evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"operator_required"}'::jsonb)`,
}).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.id, existing.id),
eq(issueRecoveryActions.fingerprint, fingerprint), eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`,
sql`(${issueRecoveryActions.nextAction} is distinct from ${nextAction} or ${issueRecoveryActions.evidence}#>>'{workspaceOwnerRecovery,state}' is distinct from 'operator_required')`,
));
}
return { acquired: false, reason: platformStopPending ? "platform_stop_pending" : "owner_unverified" } as const;
}
if (run.issueId) await recovery.upsertSourceScoped({
companyId: input.companyId, sourceIssueId: run.issueId,
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId,
cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED,
fingerprint,
evidence: { runId: input.runId, owner: rawPrior, workspaceOwnerRecovery: { state: "operator_required" } },
nextAction,
wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true,
});
return { acquired: false, reason: platformStopPending ? "platform_stop_pending" : "owner_unverified" } as const;
};
// The harness checks the exact owned deployment, including orphaned children.
// A live deployment, unsigned answer or older receipt stays fenced.
if (prior?.controllerBootId === identity.bootId && prior.pid === identity.pid
&& activeOwnerTokens.has(prior.token)) {
return { acquired: false, reason: input.retainedRepairActionId ? "owner_unverified"
: activeRunOwners.get(runKey)?.db === input.db ? "owner_active" : "busy" } as const;
}
if (prior?.worker) {
const check = await requestWorkspaceWorkerStopProof({ runId: input.runId, ownerToken: prior.token, worker: prior.worker });
platformProof = check.status === "verified" ? check.proof : null;
platformStopPending = check.status === "pending";
}
if (!prior) return requireStopVerification();
if (!platformProof && prior.hostname !== owner.hostname) return requireStopVerification();
const knownJoinedHere = prior.controllerBootId === identity.bootId
&& prior.pid === identity.pid && joinedOwnerTokens.has(prior.token)
&& !activeOwnerTokens.has(prior.token);
if (!platformProof && !knownJoinedHere) {
const takeover = await evaluateNativeControllerTakeover({
owner: { leaseOwner: prior.token, leaseExpiresAt: new Date(0), controllerPid: prior.pid,
controllerProcessStartedAt: new Date(prior.processStartedAt) }, now: new Date(),
});
if (!takeover.allowed) return requireStopVerification();
// A dead parent may have orphaned tar/Git children. Only a durable
// completed-copyback barrier proves they joined; otherwise board proof
// is required even on the same host. Both callers reuse this barrier.
const [completedCopyback] = await input.db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, input.companyId),
eq(workspaceOperations.heartbeatRunId, input.runId),
eq(workspaceOperations.issueId, run.issueId!),
eq(workspaceOperations.phase, "workspace_finalize"),
eq(workspaceOperations.status, "succeeded"),
)).limit(1);
if (!completedCopyback) return requireStopVerification();
}
}
// Claim before the potentially ambiguous write so finally can remove
// only our token even if the server committed but its response was lost.
const claimOwner = async (db: Db) => {
activeOwnerTokens.add(owner.token);
activeRunOwners.set(runKey, { token: owner.token, db: input.db });
claimed = true;
const [claimedRun] = await db.update(heartbeatRuns).set({
runnerProfileJson: sql`jsonb_set(coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb), array[${OWNER_KEY}], ${JSON.stringify(owner)}::jsonb) || ${JSON.stringify(platformProof ? { nativeWorkspaceOwnerStopProof: platformProof } : {})}::jsonb`,
}).where(and(scope, prior
? sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY} = ${JSON.stringify(rawPrior)}::jsonb`
: sql`${heartbeatRuns.runnerProfileJson}->>${OWNER_KEY} is null`
)).returning({ id: heartbeatRuns.id });
return claimedRun;
};
// A stop receipt proves the old worker exited, not that its task is still
// current. Revalidate after the provider read and atomically with the CAS.
const claimedRun = platformProof ? await input.db.transaction(async tx => {
const result = await currentRecoveryResult(tx as unknown as Db);
if (!result) return null;
const claimedRun = await claimOwner(tx as unknown as Db);
if (claimedRun) automaticResultId = result.id;
return claimedRun;
}) : await claimOwner(input.db);
if (!claimedRun) {
activeOwnerTokens.delete(owner.token);
if (activeRunOwners.get(runKey)?.token === owner.token) activeRunOwners.delete(runKey);
claimed = false;
return { acquired: false, reason: "binding_changed" } as const;
}
if (platformProof && run.issueId) await logActivity(input.db, {
companyId: input.companyId, actorType: "system", actorId: "workspace-recovery",
action: "issue.workspace_owner_stop_verified", entityType: "issue", entityId: run.issueId,
runId: input.runId, details: { proof: platformProof },
});
if (prior) joinedOwnerTokens.delete(prior.token);
const ownership: NativeWorkspaceFinalizationOwnership = {
token: owner.token,
assertHeld: async () => {
try {
// A failed transaction is never transparently reconnected by the DB client.
await lock.execute(sql`select 1`);
const [current] = await input.db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(owned).limit(1);
if (!current) throw new Error("owner_replaced");
if (automaticResultId) {
const result = await input.db.transaction(tx => currentRecoveryResult(tx as unknown as Db));
if (result?.id !== automaticResultId) throw new Error("task_replaced");
}
} catch (cause) {
throw new NativeWorkspaceFinalizationOwnershipLostError({ cause });
}
},
};
await ownership.assertHeld();
if (platformProof && run.issueId) await input.db.update(issueRecoveryActions).set({
evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"resuming"}'::jsonb)`,
updatedAt: new Date(),
}).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, run.issueId),
eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED),
eq(issueRecoveryActions.fingerprint, nativeSha256({ runId: input.runId, owner: rawPrior })),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
));
finishAcquisition();
actionPromise = action(ownership);
const value = await actionPromise;
await ownership.assertHeld();
return { acquired: true, value } as const;
});
}));
} catch (error) {
finishAcquisition(true);
throw error;
} finally {
finishAcquisition();
// postgres.js may reject the transaction when the socket dies before the
// callback settles. Join that callback before releasing its durable fence.
await actionPromise?.catch(() => undefined);
if (claimed) {
activeOwnerTokens.delete(owner.token);
if (activeRunOwners.get(runKey)?.token === owner.token) activeRunOwners.delete(runKey);
joinedOwnerTokens.add(owner.token);
try {
await input.db.transaction(async tx => {
await notifyNativeResultCommit(tx, { companyId: input.companyId, runId: input.runId });
await notifyNativeWorkspaceFinalization(tx, { companyId: input.companyId, runId: input.runId });
await notifyNativeStatusReassessment(tx, { companyId: input.companyId, runId: input.runId });
await notifyQueuedRunDispatch(tx, { companyId: input.companyId });
// Saved messages also wait on this owner after the run finished.
await notifyDeferredExecutionWork(tx, { companyId: input.companyId });
await tx.update(heartbeatRuns).set({
runnerProfileJson: sql`coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb) - ${OWNER_KEY}`,
}).where(owned);
});
joinedOwnerTokens.delete(owner.token);
} catch {
// A reconnect in this exact controller boot can use the positive join
// receipt. A new boot still needs durable barrier or operator proof.
} finally {
releaseNativeWorkspaceFinalization(input.db, { companyId: input.companyId, runId: input.runId });
}
}
}
}