mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
fix: resume saved user messages after execution recovery (#13270)
Preserve verified native process-stop evidence and retry saved user messages through normal continuation admission after recovery cleanup. Show the current wait reason and serialize delivery so a saved message starts one fresh turn. Validated with 410 focused tests, typecheck, build, token gates, all PR CI checks, and Greptile 5/5. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
250deab910
commit
51b0e01ead
17 files changed
+471
-95
No files matched your search
@@ -213,6 +213,21 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
|
||||
return queueRunId;
|
||||
}
|
||||
|
||||
it("projects the recovery wait reason only while the message is deferred", async () => {
|
||||
const seeded = await seedQueue();
|
||||
const executionWait = { reason: "remote_cleanup", message: "Waiting for the previous environment to stop." };
|
||||
await db.update(agentWakeupRequests).set({
|
||||
payload: sql`coalesce(${agentWakeupRequests.payload}, '{}'::jsonb) || ${JSON.stringify({ executionWait })}::jsonb`,
|
||||
}).where(eq(agentWakeupRequests.id, seeded.wakeId));
|
||||
const waiting = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`);
|
||||
expect(waiting.status).toBe(200);
|
||||
expect(waiting.body.executionWait).toEqual(executionWait);
|
||||
await promoteQueue(seeded);
|
||||
const admitted = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`);
|
||||
expect(admitted.status).toBe(200);
|
||||
expect(admitted.body.executionWait).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the authoritative order, preserves full Markdown edits, and rejects stale revisions", async () => {
|
||||
const seeded = await seedQueue();
|
||||
const initial = await request(app(seeded.companyId))
|
||||
|
||||
@@ -6889,8 +6889,11 @@ export function issueRoutes(
|
||||
?? (await getNativeSessionSteeringState(steering.steeringRunId)
|
||||
.then((state) => state.disposition)
|
||||
.catch(() => "temporarily_unavailable" as const));
|
||||
const wait = queueState?.state === "deferred" ? readObject(readObject(wake?.payload).executionWait) : {};
|
||||
return buildQueuedCommentQueueSnapshot({
|
||||
issueId: input.issue.id,
|
||||
executionWait: typeof wait.reason === "string" && typeof wait.message === "string"
|
||||
? { reason: wait.reason, message: wait.message } : null,
|
||||
queueId: wake?.id ?? null,
|
||||
state: queueState?.state ?? null,
|
||||
activeRunId: input.activeRun?.id ?? null,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
|
||||
import { buildExecutionContinuation } from "./execution-continuation.js";
|
||||
import { heartbeatService, type HeartbeatEnvironmentRuntime } from "./heartbeat.js";
|
||||
import { heartbeatService, persistHeartbeatRunProcessMetadata, type HeartbeatEnvironmentRuntime } from "./heartbeat.js";
|
||||
import { getExecutionBlocker } from "./execution-blocker.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("explicit native conversation continuation", () => {
|
||||
@@ -47,6 +49,104 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
agentId: f.agentId, status: "queued", contextSnapshot: { issueId: f.issueId, previousRunId: result.previousRunId, forceFreshSession: true } });
|
||||
return result;
|
||||
});
|
||||
it("preserves local stop proof after process metadata is cleared and invalidates it on another launch", async () => {
|
||||
const f = await seed();
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await db.transaction(async tx => {
|
||||
expect(await recordNativeLocalProcessStop(tx as unknown as typeof db, source)).toBe(true);
|
||||
await tx.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, source.id));
|
||||
});
|
||||
expect(await hasNativeLocalProcessStop(db, f.companyId, source.id)).toBe(true);
|
||||
expect(await hasNativeLocalProcessStop(db, randomUUID(), source.id)).toBe(false);
|
||||
expect(await admit(f, true)).toMatchObject({ previousRunId: source.id });
|
||||
await persistHeartbeatRunProcessMetadata(db, source.id, { pid: 999999999, processGroupId: null, startedAt: new Date().toISOString() });
|
||||
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, source.id));
|
||||
expect(await admit(f, true)).toBeNull();
|
||||
expect(await recordNativeLocalProcessStop(db, source)).toBe(true);
|
||||
await appendHeartbeatRunEvent(db, { companyId: f.companyId, runId: source.id, agentId: f.agentId,
|
||||
eventType: PROCESS_START_REQUESTED });
|
||||
// No PID was stored for the new launch, as when the server dies after spawn.
|
||||
expect(await admit(f, true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["live", "remote", "provider_event"])("does not accept invalid local stop proof: %s", async kind => {
|
||||
const f = await seed();
|
||||
if (kind === "remote") {
|
||||
const [environment] = await db.insert(environments).values({ name: "Remote stop", driver: "sandbox" }).returning();
|
||||
await db.insert(environmentLeases).values({ companyId: f.companyId, heartbeatRunId: f.sourceRunId,
|
||||
environmentId: environment.id, provider: "daytona", status: "active", leasePolicy: "ephemeral" });
|
||||
}
|
||||
if (kind === "live") await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
if (kind === "provider_event") {
|
||||
await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, runId: source.id, agentId: f.agentId,
|
||||
eventType: "native.local_process_stopped", seq: 1, sourceEventId: randomUUID() });
|
||||
} else expect(await recordNativeLocalProcessStop(db, source)).toBe(false);
|
||||
expect(await hasNativeLocalProcessStop(db, f.companyId, source.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("resumes saved local messages after restart exactly once and keeps the same wait receipt while blocked", async () => {
|
||||
const f = await seed();
|
||||
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
|
||||
await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
// A prior cancelled admission is also held, but cannot select the native
|
||||
// retry source. The newest blocker must win just as it does on Send.
|
||||
const oldAdmissionId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: oldAdmissionId, companyId: f.companyId, agentId: f.agentId,
|
||||
status: "cancelled", errorCode: "execution_reconciliation_required", contextSnapshot: { issueId: f.issueId },
|
||||
finishedAt: new Date("2026-09-11T09:00:00Z") });
|
||||
await db.update(issueRecoveryActions).set({ updatedAt: new Date(0), evidence: { runId: oldAdmissionId,
|
||||
automaticRecovery: { replay: "blocked", actionOutcome: "unknown" } } }).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
|
||||
kind: "active_run_watchdog", cause: "uncertain_external_action", fingerprint: randomUUID(), status: "active",
|
||||
nextAction: "Waiting for the current run.", evidence: { runId: f.sourceRunId } });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.sourceRunId });
|
||||
await heartbeatService(db).wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented",
|
||||
requestedByActorType: "user", requestedByActorId: "board", payload: { issueId: f.issueId, commentId: f.commentId },
|
||||
contextSnapshot: { issueId: f.issueId, wakeCommentId: f.commentId } });
|
||||
const [waiting] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
|
||||
expect(waiting.payload?.executionWait).toMatchObject({ reason: "process_running" });
|
||||
const makeDue = () => db.update(agentWakeupRequests).set({ updatedAt: new Date(0) }).where(eq(agentWakeupRequests.id, waiting.id));
|
||||
await makeDue();
|
||||
await heartbeatService(db).resumeExecutionWaitComments();
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
|
||||
await db.update(heartbeatRuns).set({ processPid: 999999999 }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await db.transaction(async tx => {
|
||||
await recordNativeLocalProcessStop(tx as unknown as typeof db, stopped);
|
||||
await tx.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, stopped.id));
|
||||
});
|
||||
const holdId = randomUUID();
|
||||
await db.insert(issueTreeHolds).values({ id: holdId, companyId: f.companyId, rootIssueId: f.issueId, mode: "pause", status: "active" });
|
||||
await db.insert(issueTreeHoldMembers).values({ companyId: f.companyId, holdId, issueId: f.issueId, depth: 0, issueTitle: "Deploy", issueStatus: "blocked" });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await makeDue();
|
||||
await heartbeatService(db).resumeExecutionWaitComments();
|
||||
}
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
const [paused] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, waiting.id));
|
||||
expect(paused.payload?.executionWait).toMatchObject({ reason: "issue_tree_hold_active" });
|
||||
await db.update(issueTreeHolds).set({ status: "released" }).where(eq(issueTreeHolds.id, holdId));
|
||||
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1, maxDailyRuns: 0 } } }).where(eq(agents.id, f.agentId));
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await makeDue();
|
||||
await heartbeatService(db).resumeExecutionWaitComments();
|
||||
}
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, f.agentId));
|
||||
await makeDue();
|
||||
await Promise.all([heartbeatService(db).resumeExecutionWaitComments(), heartbeatService(db).resumeExecutionWaitComments()]);
|
||||
const runs = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.sourceRunId,
|
||||
explicitUserContinuation: { commentId: f.commentId } });
|
||||
const [adopted] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, waiting.id));
|
||||
expect(adopted).toMatchObject({ status: "coalesced", runId: runs[0].id });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])("acknowledges a legacy remote Stop only after confirmed lease cleanup: %s", async confirmed => {
|
||||
const f = await seed();
|
||||
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
|
||||
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
|
||||
import { hasRemoteTerminationReceipt, remoteLeaseCleanupScope } from "./remote-execution-termination.js";
|
||||
import { z } from "zod";
|
||||
@@ -29,8 +30,10 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
actorType: string | null | undefined; actorId: string | null | undefined;
|
||||
reason: string | null; commentId: string | null; successorRunId: string;
|
||||
dryRun?: boolean;
|
||||
onBlocked?: (reason: string, message: string) => void;
|
||||
}): Promise<{ previousRunId: string; commentId: string } | null> {
|
||||
const { db, companyId, issueId, agentId, actorId, commentId } = input;
|
||||
const blocked = (reason: string, message: string) => { input.onBlocked?.(reason, message); return null; };
|
||||
if (input.actorType !== "user" || !actorId || !commentId ||
|
||||
!["issue_commented", "issue_reopened_via_comment"].includes(input.reason ?? "")) return null;
|
||||
if (!z.string().guid().safeParse(commentId).success) return null;
|
||||
@@ -60,20 +63,21 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
eq(approvals.id, issueApprovals.approvalId), eq(approvals.companyId, companyId),
|
||||
)).where(and(eq(issueApprovals.companyId, companyId), eq(issueApprovals.issueId, issueId),
|
||||
inArray(approvals.status, ["pending", "revision_requested"]))).limit(1);
|
||||
if (pendingInteraction || pendingApproval) return null;
|
||||
if (pendingInteraction || pendingApproval) return blocked("decision_pending", "A pending approval or question must be resolved before this message can start.");
|
||||
|
||||
const sources: Run[] = [];
|
||||
for (const action of actions) {
|
||||
const runId = action.evidence.runId ?? action.evidence.sourceRunId;
|
||||
if (typeof runId !== "string") return null;
|
||||
if (typeof runId !== "string") return blocked("source_missing", "The stopped run could not be identified. Your message is saved.");
|
||||
// Text comparison keeps malformed historical evidence a hold, not a UUID cast error.
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, companyId), sql`${heartbeatRuns.id}::text = ${runId}`,
|
||||
));
|
||||
if (!run || run.agentId !== agentId || !terminal.includes(run.status) ||
|
||||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId ||
|
||||
!run.finishedAt || comment.createdAt <= run.finishedAt) return null;
|
||||
if (adapterExecutionControls.has(run.id)) return null;
|
||||
!run.finishedAt) return blocked("source_unavailable", "The previous execution has not finished or its owner changed. Your message is saved.");
|
||||
if (comment.createdAt <= run.finishedAt) return blocked("message_predates_stop", "This message arrived before the previous run stopped. Send a new message to continue.");
|
||||
if (adapterExecutionControls.has(run.id)) return blocked("execution_settling", "Waiting for the previous run to stop. Your message will start automatically.");
|
||||
const unusedAdmission = run.status === "cancelled" && !run.startedAt &&
|
||||
run.errorCode === "execution_reconciliation_required" &&
|
||||
!run.processPid && !run.processGroupId && !run.nativeSessionId;
|
||||
@@ -82,7 +86,7 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
eq(nativeRunFinalizations.companyId, companyId), eq(nativeRunFinalizations.runId, run.id),
|
||||
)).for("update");
|
||||
if (coordinator && (coordinator.phase !== "terminal_failure" || coordinator.leaseOwner ||
|
||||
coordinator.resultId || coordinator.failureDetail?.successorRunId)) return null;
|
||||
coordinator.resultId || coordinator.failureDetail?.successorRunId)) return blocked("controller_settling", "Waiting for the previous run to finish recovery. Your message will start automatically.");
|
||||
const leases = await db.select()
|
||||
.from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
||||
@@ -90,17 +94,18 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
const remote = leases.some(lease => lease.provider !== "local");
|
||||
if (remote) {
|
||||
// Never interpret remote PIDs using the control-plane host's process table.
|
||||
if (!leases.every(hasRemoteTerminationReceipt)) return null;
|
||||
if (!leases.every(hasRemoteTerminationReceipt)) return blocked("remote_cleanup", "Waiting for the previous environment to stop. Your message will start automatically.");
|
||||
if (!input.dryRun && !leases.every(lease => completeTerminatedRemoteNativeSessionCleanup({
|
||||
companyId, runId: run.id, remoteCleanupScope: remoteLeaseCleanupScope(lease)!,
|
||||
}))) return null;
|
||||
} else {
|
||||
if (leases.some(lease => !lease.releasedAt || lease.cleanupStatus === "failed")) return null;
|
||||
if (leases.some(lease => !lease.releasedAt || lease.cleanupStatus === "failed")) return blocked("local_cleanup", "Waiting for the previous environment to finish cleanup. Your message will start automatically.");
|
||||
if (!unusedAdmission) {
|
||||
// A missing process identity is not evidence that a provider exited.
|
||||
if (!run.processPid && !run.processGroupId) return null;
|
||||
if (run.processPid && !processStopped(run.processPid)) return null;
|
||||
if (run.processGroupId && !processStopped(-run.processGroupId)) return null;
|
||||
if (!run.processPid && !run.processGroupId &&
|
||||
!await hasNativeLocalProcessStop(db, companyId, run.id)) return blocked("process_identity_missing", "The previous run has no verified stop record. Paperclip cannot start this message yet.");
|
||||
if (run.processPid && !processStopped(run.processPid)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically.");
|
||||
if (run.processGroupId && !processStopped(-run.processGroupId)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically.");
|
||||
}
|
||||
}
|
||||
sources.push(run);
|
||||
@@ -113,7 +118,7 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
inArray(heartbeatRuns.status, ["running", "queued", "scheduled_retry"]),
|
||||
ne(heartbeatRuns.id, input.successorRunId),
|
||||
)).limit(1);
|
||||
if (active) return null;
|
||||
if (active) return blocked("execution_active", "Waiting for the current run. Your message is saved.");
|
||||
const previous = nativeSources.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!;
|
||||
// Prove required task history is available before retiring any hold.
|
||||
await buildExecutionContinuation({ db, companyId, issueId, agentId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js";
|
||||
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
|
||||
import { remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
|
||||
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
|
||||
import { getExecutionBlocker } from "./execution-blocker.js";
|
||||
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
|
||||
import { CONVERSATION_CONTINUATION_POLICY, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
|
||||
import { recordExecutionWait } from "./execution-wait.js";
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
getTableColumns,
|
||||
gt,
|
||||
gte,
|
||||
@@ -8599,19 +8601,27 @@ export async function persistHeartbeatRunProcessMetadata(
|
||||
() => null,
|
||||
);
|
||||
const startedAt = new Date(observedStartedAt ?? meta.startedAt);
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: meta.pid,
|
||||
processGroupId: meta.processGroupId,
|
||||
processStartedAt: Number.isNaN(startedAt.getTime())
|
||||
? new Date()
|
||||
: startedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return db.transaction(async tx => {
|
||||
const run = await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: meta.pid,
|
||||
processGroupId: meta.processGroupId,
|
||||
processStartedAt: Number.isNaN(startedAt.getTime())
|
||||
? new Date()
|
||||
: startedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (run?.runtimeMode === "native") await appendHeartbeatRunEvent(tx as unknown as Db, {
|
||||
companyId: run.companyId, runId, agentId: run.agentId,
|
||||
eventType: PROCESS_IDENTITY_RECORDED, stream: "system", level: "info",
|
||||
message: "Process identity recorded; prior stop evidence no longer applies.",
|
||||
});
|
||||
return run;
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateHeartbeatRunProcess(input: {
|
||||
@@ -9949,9 +9959,9 @@ export function heartbeatService(
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeRemoteStopComments(run: typeof heartbeatRuns.$inferSelect) {
|
||||
if (!isHeartbeatRunTerminalStatus(run.status) || adapterExecutionControls.has(run.id) ||
|
||||
!(await remoteExecutionHasStopped(db, run.companyId, run.id))) return;
|
||||
async function resumeRemoteStopComments(run: typeof heartbeatRuns.$inferSelect, requestId?: string) {
|
||||
if (!isHeartbeatRunTerminalStatus(run.status) || adapterExecutionControls.has(run.id)) return;
|
||||
if (run.runtimeMode !== "native" && !(await remoteExecutionHasStopped(db, run.companyId, run.id))) return;
|
||||
const issueId = run.nativeIssueId ?? (typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null);
|
||||
if (!issueId) return;
|
||||
const legacyContinuation = run.runtimeMode === "legacy" && run.status === "cancelled" &&
|
||||
@@ -9962,9 +9972,11 @@ export function heartbeatService(
|
||||
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
eq(agentWakeupRequests.requestedByActorType, "user"),
|
||||
requestId ? eq(agentWakeupRequests.id, requestId) : undefined,
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
||||
)).orderBy(asc(agentWakeupRequests.requestedAt));
|
||||
)).orderBy(asc(agentWakeupRequests.requestedAt)).limit(50);
|
||||
for (const wake of pending) {
|
||||
if (wake.idempotencyKey?.startsWith("chat-inbound:")) continue;
|
||||
const payload = parseObject(wake.payload);
|
||||
const context = parseObject(payload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||
const commentId = deriveCommentId(context, payload);
|
||||
@@ -9978,19 +9990,76 @@ export function heartbeatService(
|
||||
isNull(issueComments.createdByRunId), gt(issueComments.createdAt, run.finishedAt),
|
||||
));
|
||||
if (!comment?.body.trim()) continue;
|
||||
} else if (!await admitExplicitNativeContinuation({ db, companyId: run.companyId, issueId,
|
||||
agentId: run.agentId, actorType: wake.requestedByActorType, actorId: wake.requestedByActorId,
|
||||
reason: wake.reason, commentId, successorRunId: randomUUID(), dryRun: true })) continue;
|
||||
} else {
|
||||
let wait = { reason: "execution_recovery", message: "Waiting for execution recovery. Your message is saved." };
|
||||
const admitted = await admitExplicitNativeContinuation({ db, companyId: run.companyId, issueId,
|
||||
agentId: run.agentId, actorType: wake.requestedByActorType, actorId: wake.requestedByActorId,
|
||||
reason: wake.reason, commentId, successorRunId: randomUUID(), dryRun: true,
|
||||
onBlocked: (reason, message) => { wait = { reason, message }; },
|
||||
});
|
||||
if (!admitted) {
|
||||
await db.update(agentWakeupRequests).set({
|
||||
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}',
|
||||
coalesce(${agentWakeupRequests.payload}->'executionWait', '{}'::jsonb) || ${JSON.stringify(wait)}::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.companyId, run.companyId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution")));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Re-enter ordinary admission with the original user's authority. It
|
||||
// atomically adopts the deferred comments and still applies every gate.
|
||||
await enqueueWakeup(run.agentId, { source: wake.source as WakeupOptions["source"], triggerDetail: (wake.triggerDetail ?? undefined) as WakeupOptions["triggerDetail"],
|
||||
reason: wake.reason, payload, contextSnapshot: context,
|
||||
requestedByActorType: "user", requestedByActorId: wake.requestedByActorId,
|
||||
idempotencyKey: `remote-stop-comment:${run.id}:${wake.id}` });
|
||||
idempotencyKey: `remote-stop-comment:${run.id}:${wake.id}` }, wake.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeExecutionWaitComments() {
|
||||
if ((await getSchedulingSuppression()).suppressed) return;
|
||||
const waits = await db.select({ wake: agentWakeupRequests })
|
||||
.from(agentWakeupRequests)
|
||||
.innerJoin(issues, and(eq(issues.companyId, agentWakeupRequests.companyId),
|
||||
sql`${issues.id}::text = ${agentWakeupRequests.payload}->>'issueId'`,
|
||||
eq(issues.assigneeAgentId, agentWakeupRequests.agentId)))
|
||||
.innerJoin(companies, and(eq(companies.id, issues.companyId), eq(companies.status, "active")))
|
||||
.where(and(exists(db.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions).where(and(
|
||||
eq(issueRecoveryActions.companyId, issues.companyId), eq(issueRecoveryActions.sourceIssueId, issues.id),
|
||||
executionBlockerPredicate(),
|
||||
))), eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
eq(agentWakeupRequests.requestedByActorType, "user"),
|
||||
sql`${agentWakeupRequests.payload}->'executionWait' is not null`,
|
||||
lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)),
|
||||
notInArray(issues.status, ["done", "cancelled"])))
|
||||
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
|
||||
const seen = new Set<string>();
|
||||
for (const { wake } of waits) {
|
||||
const issueId = String(wake.payload?.issueId);
|
||||
if (seen.has(issueId)) continue;
|
||||
seen.add(issueId);
|
||||
// Advance the cursor even for invalid evidence so one damaged task cannot
|
||||
// starve later requests in this bounded scan. Preserve concurrent edits.
|
||||
const [claimed] = await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and(
|
||||
eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.companyId, wake.companyId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)),
|
||||
)).returning({ id: agentWakeupRequests.id });
|
||||
if (!claimed) continue;
|
||||
// Match normal admission's deterministic current blocker selection. An
|
||||
// arbitrary historical action must not choose the retry's source run.
|
||||
const blocker = await getExecutionBlocker(db, wake.companyId, issueId);
|
||||
const sourceId = blocker?.runId;
|
||||
if (!sourceId || !isUuidLike(sourceId)) continue;
|
||||
const run = await getRun(sourceId);
|
||||
if (!run || run.companyId !== wake.companyId || run.agentId !== wake.agentId) continue;
|
||||
await resumeRemoteStopComments(run, wake.id).catch(err => {
|
||||
logger.warn({ err, runId: run.id }, "failed to resume saved execution-wait message");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function hasUnsafeTextProjectionDatabase() {
|
||||
if (!unsafeTextProjectionPromise) {
|
||||
unsafeTextProjectionPromise = db
|
||||
@@ -18016,31 +18085,35 @@ export function heartbeatService(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const cleared = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, run.id),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
run.processPid === null
|
||||
? isNull(heartbeatRuns.processPid)
|
||||
: eq(heartbeatRuns.processPid, run.processPid),
|
||||
run.processGroupId === null
|
||||
? isNull(heartbeatRuns.processGroupId)
|
||||
: eq(heartbeatRuns.processGroupId, run.processGroupId),
|
||||
run.processStartedAt === null
|
||||
? isNull(heartbeatRuns.processStartedAt)
|
||||
: eq(heartbeatRuns.processStartedAt, run.processStartedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: heartbeatRuns.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const cleared = await db.transaction(async tx => {
|
||||
const cleared = await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, run.id),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
run.processPid === null
|
||||
? isNull(heartbeatRuns.processPid)
|
||||
: eq(heartbeatRuns.processPid, run.processPid),
|
||||
run.processGroupId === null
|
||||
? isNull(heartbeatRuns.processGroupId)
|
||||
: eq(heartbeatRuns.processGroupId, run.processGroupId),
|
||||
run.processStartedAt === null
|
||||
? isNull(heartbeatRuns.processStartedAt)
|
||||
: eq(heartbeatRuns.processStartedAt, run.processStartedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: heartbeatRuns.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (cleared) await recordNativeLocalProcessStop(tx as unknown as Db, run);
|
||||
return cleared;
|
||||
});
|
||||
if (cleared) claimableNativeRunIds.add(cleared.id);
|
||||
}
|
||||
|
||||
@@ -18474,6 +18547,7 @@ export function heartbeatService(
|
||||
|
||||
async function resumeQueuedRuns() {
|
||||
if ((await getSchedulingSuppression()).suppressed) return;
|
||||
await resumeExecutionWaitComments();
|
||||
const cutoff = await getWorktreeExecutionCutoff();
|
||||
|
||||
const queuedRuns = await db
|
||||
@@ -19106,31 +19180,35 @@ export function heartbeatService(
|
||||
}
|
||||
runningProcesses.delete(run.id);
|
||||
if (run.processPid || run.processGroupId || run.processStartedAt) {
|
||||
const cleared = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, run.id),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
run.processPid === null
|
||||
? isNull(heartbeatRuns.processPid)
|
||||
: eq(heartbeatRuns.processPid, run.processPid),
|
||||
run.processGroupId === null
|
||||
? isNull(heartbeatRuns.processGroupId)
|
||||
: eq(heartbeatRuns.processGroupId, run.processGroupId),
|
||||
run.processStartedAt === null
|
||||
? isNull(heartbeatRuns.processStartedAt)
|
||||
: eq(heartbeatRuns.processStartedAt, run.processStartedAt),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const cleared = await db.transaction(async tx => {
|
||||
const cleared = await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, run.id),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
run.processPid === null
|
||||
? isNull(heartbeatRuns.processPid)
|
||||
: eq(heartbeatRuns.processPid, run.processPid),
|
||||
run.processGroupId === null
|
||||
? isNull(heartbeatRuns.processGroupId)
|
||||
: eq(heartbeatRuns.processGroupId, run.processGroupId),
|
||||
run.processStartedAt === null
|
||||
? isNull(heartbeatRuns.processStartedAt)
|
||||
: eq(heartbeatRuns.processStartedAt, run.processStartedAt),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (cleared) await recordNativeLocalProcessStop(tx as unknown as Db, run);
|
||||
return cleared;
|
||||
});
|
||||
if (!cleared) {
|
||||
const current = await getRun(run.id);
|
||||
if (current) {
|
||||
@@ -24818,7 +24896,7 @@ export function heartbeatService(
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}) {
|
||||
async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string) {
|
||||
const source = opts.source ?? "on_demand";
|
||||
const triggerDetail = opts.triggerDetail ?? null;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
@@ -24946,6 +25024,20 @@ export function heartbeatService(
|
||||
patch: Partial<typeof agentWakeupRequests.$inferInsert> = {},
|
||||
waitCondition?: Record<string, unknown>,
|
||||
) => {
|
||||
if (executionWaitRequestId) {
|
||||
await db.update(agentWakeupRequests).set({
|
||||
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}',
|
||||
coalesce(${agentWakeupRequests.payload}->'executionWait', '{}'::jsonb) || ${JSON.stringify({
|
||||
reason: skipReason, message: patch.error ?? (skipReason === "issue_tree_hold_active"
|
||||
? "This task is paused. Resume it to send your saved message."
|
||||
: "Waiting for task execution to be enabled. Your message is saved."),
|
||||
})}::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(agentWakeupRequests.id, executionWaitRequestId),
|
||||
eq(agentWakeupRequests.companyId, agent.companyId), eq(agentWakeupRequests.agentId, agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution")));
|
||||
return { created: false };
|
||||
}
|
||||
const request = {
|
||||
...durableReceiptFields,
|
||||
companyId: agent.companyId,
|
||||
@@ -25191,7 +25283,7 @@ export function heartbeatService(
|
||||
|
||||
const invokability = await getAgentInvokability(agent);
|
||||
if (!invokability.invokable) {
|
||||
if (opts.requestedByActorType !== "user") {
|
||||
if (opts.requestedByActorType !== "user" || executionWaitRequestId) {
|
||||
await writeSkippedRequest("agent.not_invokable", {
|
||||
error: invokability.message,
|
||||
}, { status: agent.status, reason: invokability.reason });
|
||||
@@ -25305,6 +25397,20 @@ export function heartbeatService(
|
||||
sql`select id from issues where id = ${issueId} and company_id = ${agent.companyId} for update`,
|
||||
);
|
||||
|
||||
if (executionWaitRequestId) {
|
||||
const [pending] = await tx.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.id, executionWaitRequestId), eq(agentWakeupRequests.companyId, agent.companyId),
|
||||
eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
eq(agentWakeupRequests.requestedByActorType, "user"),
|
||||
eq(agentWakeupRequests.requestedByActorId, opts.requestedByActorId ?? ""),
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
||||
));
|
||||
// The issue lock serializes cleanup callbacks and periodic workers.
|
||||
// An adopted, discarded, or edited receipt is no longer authority.
|
||||
if (!pending || !wakeCommentId || !queuedCommentIdsFromWakePayload(pending.payload).includes(wakeCommentId)) {
|
||||
return { kind: "deferred" as const };
|
||||
}
|
||||
}
|
||||
let automaticParentRunId: string | null = null;
|
||||
if (
|
||||
source === "automation" &&
|
||||
@@ -25609,10 +25715,18 @@ export function heartbeatService(
|
||||
reconciledSourceRunId = sourceRunId;
|
||||
}
|
||||
|
||||
let continuationWait = { reason: "execution_recovery", message: "Waiting for execution recovery. Your message is saved." };
|
||||
const deferBlockedExecution = async (
|
||||
executionBlocker: NonNullable<Awaited<ReturnType<typeof getExecutionBlocker>>>,
|
||||
) => {
|
||||
const condition = { recoveryActionId: executionBlocker.recoveryActionId };
|
||||
const condition = { recoveryActionId: executionBlocker.recoveryActionId, ...continuationWait };
|
||||
if (executionWaitRequestId) {
|
||||
await tx.update(agentWakeupRequests).set({
|
||||
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', ${JSON.stringify(condition)}::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(agentWakeupRequests.id, executionWaitRequestId));
|
||||
return { kind: "deferred" as const };
|
||||
}
|
||||
if (durableRequest || wakeCommentId || hasInteractionContinuationWakeContext(enrichedContextSnapshot)) {
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
...durableReceiptFields,
|
||||
@@ -25659,6 +25773,7 @@ export function heartbeatService(
|
||||
agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId,
|
||||
reason, commentId: wakeCommentId ?? null, successorRunId: explicitContinuationRunId,
|
||||
dryRun: true,
|
||||
onBlocked: (reason, message) => { continuationWait = { reason, message }; },
|
||||
}))) return deferBlockedExecution(executionBlocker);
|
||||
|
||||
const issueStateGuard = opts.issueStateGuard;
|
||||
@@ -26375,6 +26490,11 @@ export function heartbeatService(
|
||||
tx,
|
||||
);
|
||||
if (dailyCapBlock) {
|
||||
if (executionWaitRequestId && executionBlocker) {
|
||||
continuationWait = { reason: dailyCapBlock.reason,
|
||||
message: "The agent has reached its daily limit. Your message is saved until work can resume." };
|
||||
return deferBlockedExecution(executionBlocker);
|
||||
}
|
||||
const now = new Date();
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
...durableReceiptFields,
|
||||
@@ -26469,7 +26589,7 @@ export function heartbeatService(
|
||||
!wake.idempotencyKey?.startsWith("chat-inbound:") &&
|
||||
!isInteractionResolutionWakePayload(deferredPayload) &&
|
||||
!hasInteractionContinuationWakeContext(deferredContext) &&
|
||||
(deferredContext.wakeReason ?? wake.reason) === "issue_commented" &&
|
||||
["issue_commented", "issue_reopened_via_comment"].includes(String(deferredContext.wakeReason ?? wake.reason)) &&
|
||||
queuedCommentIdsFromWakePayload(wake.payload).length > 0
|
||||
);
|
||||
});
|
||||
@@ -27995,6 +28115,7 @@ export function heartbeatService(
|
||||
|
||||
releaseEnvironmentLeasesForRun,
|
||||
resumeRemoteStopComments,
|
||||
resumeExecutionWaitComments,
|
||||
|
||||
sweepStaleIssueLocks,
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ export function buildQueuedCommentQueueSnapshot<TComment extends QueuedCommentQu
|
||||
protocol: QueuedCommentQueueProtocol;
|
||||
steeringDisposition: IssueQueuedCommentQueue["steeringDisposition"];
|
||||
comments: TComment[];
|
||||
executionWait?: IssueQueuedCommentQueue["executionWait"];
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
}): IssueQueuedCommentQueue {
|
||||
@@ -188,6 +189,7 @@ export function buildQueuedCommentQueueSnapshot<TComment extends QueuedCommentQu
|
||||
revision: queuedCommentQueueRevision({ queueId: facts.queueId, comments: facts.comments }),
|
||||
protocol: facts.protocol,
|
||||
steeringDisposition: facts.steeringDisposition,
|
||||
...(facts.executionWait ? { executionWait: facts.executionWait } : {}),
|
||||
entries: facts.comments.map((comment, position) => ({
|
||||
comment: comment as unknown as IssueComment,
|
||||
position,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
|
||||
export const PROCESS_START_REQUESTED = "native.process_start_requested";
|
||||
export const PROCESS_IDENTITY_RECORDED = "native.process_identity_recorded";
|
||||
const LOCAL_PROCESS_STOPPED = "native.local_process_stopped";
|
||||
|
||||
function absent(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the host's observation before recovery clears the process fields.
|
||||
* Call in the same transaction as that compare-and-set. Never inspect remote
|
||||
* process IDs in the control-plane host's process table.
|
||||
*/
|
||||
export async function recordNativeLocalProcessStop(db: Db, run: typeof heartbeatRuns.$inferSelect) {
|
||||
if (run.runtimeMode !== "native" || (!run.processPid && !run.processGroupId)) return false;
|
||||
const leases = await db.select({ provider: environmentLeases.provider })
|
||||
.from(environmentLeases)
|
||||
.where(and(
|
||||
eq(environmentLeases.companyId, run.companyId),
|
||||
eq(environmentLeases.heartbeatRunId, run.id),
|
||||
));
|
||||
if (leases.some(lease => lease.provider !== "local")) return false;
|
||||
if ((run.processPid && !absent(run.processPid)) ||
|
||||
(run.processGroupId && !absent(-run.processGroupId))) return false;
|
||||
await appendHeartbeatRunEvent(db, {
|
||||
companyId: run.companyId,
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
eventType: LOCAL_PROCESS_STOPPED,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "Recovery verified the local process stopped before clearing its identity.",
|
||||
payload: { processPid: run.processPid, processGroupId: run.processGroupId },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only server-authored evidence counts. A later launch invalidates the receipt. */
|
||||
export async function hasNativeLocalProcessStop(db: Db, companyId: string, runId: string) {
|
||||
const [event] = await db.select({ eventType: heartbeatRunEvents.eventType })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(
|
||||
eq(heartbeatRunEvents.companyId, companyId),
|
||||
eq(heartbeatRunEvents.runId, runId),
|
||||
isNull(heartbeatRunEvents.sourceEventId),
|
||||
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]),
|
||||
))
|
||||
.orderBy(desc(heartbeatRunEvents.seq))
|
||||
.limit(1);
|
||||
return event?.eventType === LOCAL_PROCESS_STOPPED;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordNativeLocalProcessStop } from "../native-local-process-stop.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -909,6 +910,10 @@ export async function claimNativeRestartRecoveries(input: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (claimKind !== "reattach_existing_runner") {
|
||||
await recordNativeLocalProcessStop(tx as unknown as Db, row.run);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasNativeLocalProcessStop } from "../native-local-process-stop.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -580,6 +581,10 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
|
||||
if (!claim || claim.kind !== "resume_dead_runner") {
|
||||
throw new Error("Expected dead-runner recovery claim");
|
||||
}
|
||||
expect(await hasNativeLocalProcessStop(fixture.db, companyId, fixture.runId)).toBe(true);
|
||||
const [stoppedRun] = await fixture.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
expect(stoppedRun.processPid).toBeNull();
|
||||
expect(stoppedRun.processGroupId).toBeNull();
|
||||
|
||||
restored = createRunnerdCodexTransport({
|
||||
...options,
|
||||
|
||||
@@ -4119,7 +4119,7 @@ function leaseDb(
|
||||
returning: () => Promise<Array<{ runId: string }>>;
|
||||
};
|
||||
result.returning = () =>
|
||||
Promise.resolve([{ runId: coordinator.runId }]);
|
||||
Promise.resolve([{ runId: coordinator.runId, nextEventSeq: 2 }]);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -4162,12 +4162,20 @@ function leaseDb(
|
||||
return query;
|
||||
},
|
||||
});
|
||||
const insert = (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
updates.push({ table, values });
|
||||
return { returning: async () => [values] };
|
||||
},
|
||||
});
|
||||
const tx = {
|
||||
insert,
|
||||
execute: async () => [],
|
||||
select,
|
||||
update,
|
||||
};
|
||||
return {
|
||||
insert,
|
||||
select,
|
||||
transaction: async (operation: (transaction: Db) => Promise<unknown>) =>
|
||||
operation(tx as unknown as Db),
|
||||
@@ -6463,13 +6471,16 @@ describe("native process ownership", () => {
|
||||
highestContiguousSourceSeq: 1,
|
||||
};
|
||||
});
|
||||
state.createBackend.mockImplementationOnce((_input, options) => ({
|
||||
kind: "test",
|
||||
onSpawn: options.onSpawn,
|
||||
}));
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
|
||||
state.createBackend.mockImplementationOnce((_input, options) => {
|
||||
expect(updates).toContainEqual({ table: heartbeatRunEvents, values: expect.objectContaining({
|
||||
eventType: "native.process_start_requested", runId: execution.binding.runId,
|
||||
}) });
|
||||
return { kind: "test", onSpawn: options.onSpawn };
|
||||
});
|
||||
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(),
|
||||
db: leaseDb(execution, {}, {}, updates),
|
||||
execution,
|
||||
runnerInstanceId: "runner",
|
||||
onSpawn,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
|
||||
import {
|
||||
@@ -7598,6 +7599,17 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
input.db,
|
||||
input.execution.binding,
|
||||
);
|
||||
// Invalidate prior stop evidence before a backend can spawn. A crash between
|
||||
// spawn and the PID callback must not make an old receipt authorize a turn.
|
||||
await appendHeartbeatRunEvent(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
runId: input.execution.binding.runId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
eventType: PROCESS_START_REQUESTED,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "Native execution requested; prior local stop evidence no longer applies.",
|
||||
});
|
||||
const runnerdBackend =
|
||||
input.useRunnerd && input.backend === undefined
|
||||
? await createRunnerdBackend({
|
||||
|
||||
Reference in new issue
Block a user