mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Before Paperclip starts a new message or a Retry after a failed native run, it proves that the run's local process stopped > - A native run can fail before native selection, for example when a budget reservation is refused or a preparation query fails. That run never had a runner, a native session or a process ID > - The stop gate still waited for a stop record of that process. Retry returned 202, then the task stayed on `retryWait.reason=process_identity_missing` and no run started > - Native selection writes the run's native issue, native session, runner instance and coordinator in one transaction. A runner launch needs all of them, and the executor records `native.process_start_requested` before any spawn > - This pull request treats a settled run with none of that evidence as having nothing to stop > - The benefit is that Retry and new messages continue after a failure that happened before the agent started ## Linked Issues or Issue Description **What happened?** In local restart-chaos database-load wave `w-20261110T050818-s20386`, all 4 tasks failed before any agent process started. One example is t01, run `f7e2ee55`. A preparation query failed, and the run ended `adapter_failed` with no native session, no runner instance, no process ID and no launch event. Retry returned 202. For 16 or more minutes, `retryWait` then stayed on `process_identity_missing` and no run started. **Expected behavior** If the previous run never had a process, there is nothing to verify, so Retry and new input continue. **Steps to reproduce** 1. Make a native run fail before native selection (for example, a refused budget reservation or a failed query). 2. Click Retry, or send a message. Before this change, the input waits on `process_identity_missing` and does not continue. ## What Changed - `native-local-process-stop.ts`: `nativeRunRefusedBeforeLaunch` (renamed `nativeRunNeverLaunched`) now also proves "never launched" for a settled native run in this state: - no process ID and no process group; - no native issue, no native session and no runner instance (native selection writes them together). A coordinator row requires the native issue by foreign key, so the native-issue check also rules out a coordinator; - no launch request, no identity record, no `adapter.invoke` and no runner-authored event (any event with a source event ID). The existing case, a run that the cleanup quarantine refused before launch, does not change. - `explicit-native-continuation.test.ts`: the guard "failed for another reason without a process record" now separates real pre-selection failures from the shapes that must stay held. - Admitted: a run that failed before it requested a launch. This case is red on `master`. - Still held on `process_identity_missing`: a run that has a runner event, a run with a prepared native session, and a run with a coordinator, for `failed`, `timed_out` and `interrupted` runs. A `timed_out` run that failed before launch is admitted. The chat suite's `unreconciled` case (a native issue and phase, no session) also stays held. ## Verification - Red/green: on `master`, `routes input after a run failed before it requested a launch` fails with `process_identity_missing`. It passes with this change. - `vitest run src/services/explicit-native-continuation.test.ts src/services/quarantined-task-retry.test.ts src/__tests__/issue-confirm-run-stopped-routes.test.ts`: 3 files, 462 tests pass. This includes every existing "keeps the hold for missing_process" and historical-run case. - Server `tsc --noEmit` passes. ## Risks - Low risk. The new proof needs structural evidence that no runner could exist: no native selection and no launch or runner event. A run that reached native selection, or that has any launch evidence, keeps every existing stop proof. - The proof does not depend on the build: a runner from any build needs the native session and the runner instance. Saved runs from older builds that were selected keep those fields, so they stay held exactly as before. - Relates to #20477, which fixes the restart window inside an answer run's attachment. This pull request is independent of it. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## 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 - [x] I will address all Greptile and reviewer comments before requesting merge Refs #19829 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
442 lines
26 KiB
TypeScript
442 lines
26 KiB
TypeScript
import { notifyLiveRunRecovery } from "./live-run-recovery-notifications.js";
|
|
import { and, asc, desc, eq, exists, gt, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
|
import { notifyRetainedNativeSessionCleanup } from "./retained-native-session-cleanup-notifications.js";
|
|
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { nativeSessionProviderEvidence } from "./native-runtime/native-session-resume.js";
|
|
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
|
import os from "node:os";
|
|
import { currentProcessSpaceHash } from "./controller-process-identity.js";
|
|
import { proveOwnerGone } from "./owner-proof.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";
|
|
const SETTLED_RUN_STATUSES = ["failed", "cancelled", "timed_out", "interrupted"];
|
|
const SETTLED_COORDINATOR_PHASES = ["committed", "terminal_failure"];
|
|
|
|
function absent(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return false;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
}
|
|
}
|
|
|
|
/** A fresh workspace is not process containment. Bind an OS observation to
|
|
* the original host, process birth, and launch event before accepting it. */
|
|
export async function recordedLocalExecutionStopped(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "processPid" | "processGroupId" | "processStartedAt"> & { processHostHash?: string | null }) {
|
|
const [identity] = await db.select({ eventType: heartbeatRunEvents.eventType, payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED]),
|
|
isNull(heartbeatRunEvents.sourceEventId),
|
|
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
|
|
const recorded = identity?.payload;
|
|
if (identity?.eventType !== PROCESS_IDENTITY_RECORDED || recorded?.targetKind !== "local" || recorded.hostname !== os.hostname() ||
|
|
recorded.processPid !== run.processPid || recorded.processGroupId !== run.processGroupId ||
|
|
!run.processStartedAt || recorded.processStartedAt !== run.processStartedAt.toISOString()) return false;
|
|
if (!run.processPid) return false;
|
|
// The shared prover judges the bound identity: the PID is dead or reused by
|
|
// a later process, and its group is empty or led by a later process. The
|
|
// recorded PID space is used when the row has one; older rows were bound to
|
|
// this host by the launch event above.
|
|
const proof = await proveOwnerGone({ kind: "local", scope: "native_local_process", generation: null, host: null,
|
|
processSpace: run.processHostHash ?? currentProcessSpaceHash(), bootId: null, pid: run.processPid,
|
|
processStartedAt: run.processStartedAt.toISOString(), processGroupId: run.processGroupId });
|
|
return proof.verdict === "gone";
|
|
}
|
|
|
|
/**
|
|
* 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: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "agentId" | "runtimeMode" | "processPid" | "processGroupId" | "processStartedAt">) {
|
|
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 notifyDeliveryWork(db, DELIVERY_QUEUES.executionReplacement,
|
|
JSON.stringify({ companyId: run.companyId, runId: run.id }));
|
|
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,
|
|
processStartedAt: run.processStartedAt?.toISOString() ?? null },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/** Transaction form for callers that clear the process identity: a stopped
|
|
* provider closes its usage source, so accounting may settle a terminal run
|
|
* whose final receipt can no longer arrive. */
|
|
export async function recordNativeLocalProcessStopInTransaction(tx: Db, run: Parameters<typeof recordNativeLocalProcessStop>[1]) {
|
|
const recorded = await recordNativeLocalProcessStop(tx, run);
|
|
if (recorded) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
|
|
return recorded;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* A local runner launch can be requested and the server killed before the
|
|
* runner's process identity is recorded. With no PID to check, prove on this
|
|
* host that no runner for the run exists and that it never wrote runner state
|
|
* (no provider can start before that), then record the stop like a verified
|
|
* PID. A later launch request invalidates the receipt as usual.
|
|
*/
|
|
export async function recordUnidentifiedLocalRunnerStop(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "agentId" | "runtimeMode" | "processPid" | "processGroupId" | "runnerProfileJson">,
|
|
options: { record: boolean } = { record: true }): Promise<boolean> {
|
|
if (run.runtimeMode !== "native" || run.processPid || run.processGroupId) return false;
|
|
const [latest] = await db.select({ eventType: heartbeatRunEvents.eventType, payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
isNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]),
|
|
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
|
|
// Only the host that requested the launch can inspect its process table.
|
|
if (latest?.eventType !== PROCESS_START_REQUESTED || latest.payload?.hostname !== os.hostname()) 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;
|
|
const { unidentifiedLocalRunnerProvenAbsent } = await import("./native-runtime/native-session-executor.js");
|
|
if (!await unidentifiedLocalRunnerProvenAbsent({ runId: run.id, execution: run.runnerProfileJson?.nativeExecutionInput })) return false;
|
|
if (!options.record) return true;
|
|
await appendHeartbeatRunEvent(db, {
|
|
companyId: run.companyId,
|
|
runId: run.id,
|
|
agentId: run.agentId,
|
|
eventType: LOCAL_PROCESS_STOPPED,
|
|
stream: "system",
|
|
level: "info",
|
|
message: "Verified that no runner for this run exists on this host and none saved runner state.",
|
|
payload: { processPid: null, processGroupId: null, processStartedAt: null, basis: "unidentified_runner_absent" },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
export class UnidentifiedRunnerStopRefusedError extends Error {
|
|
constructor(readonly code: string, message: string) {
|
|
super(message);
|
|
this.name = "UnidentifiedRunnerStopRefusedError";
|
|
}
|
|
}
|
|
|
|
type UnidentifiedLaunchRun = Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "runtimeMode" | "status" | "finishedAt" | "processPid" | "processGroupId">;
|
|
type LaunchRefusal = { code: string; message: string } | "already_stopped" | null;
|
|
|
|
export async function unidentifiedLocalLaunchRefusals(db: Db, runs: UnidentifiedLaunchRun[]): Promise<Map<string, LaunchRefusal>> {
|
|
const result = new Map<string, LaunchRefusal>();
|
|
const candidates: UnidentifiedLaunchRun[] = [];
|
|
for (const run of runs) {
|
|
if (run.runtimeMode !== "native" || !run.finishedAt || !FINISHED_RUN_STATUSES.has(run.status))
|
|
result.set(run.id, { code: "run_not_finished", message: "Only a finished run's stop can be confirmed." });
|
|
else if (run.processPid || run.processGroupId)
|
|
result.set(run.id, { code: "process_identity_recorded", message: "This run recorded its process. Paperclip verifies that process itself." });
|
|
else candidates.push(run);
|
|
}
|
|
if (!candidates.length) return result;
|
|
const ids = candidates.map(run => run.id);
|
|
const companyIds = [...new Set(candidates.map(run => run.companyId))];
|
|
const latest = await db.selectDistinctOn([heartbeatRunEvents.runId], { runId: heartbeatRunEvents.runId,
|
|
eventType: heartbeatRunEvents.eventType, payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
|
|
inArray(heartbeatRunEvents.companyId, companyIds), inArray(heartbeatRunEvents.runId, ids),
|
|
isNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]),
|
|
)).orderBy(heartbeatRunEvents.runId, desc(heartbeatRunEvents.seq));
|
|
const remote = new Set((await db.select({ runId: environmentLeases.heartbeatRunId }).from(environmentLeases).where(and(
|
|
inArray(environmentLeases.companyId, companyIds), inArray(environmentLeases.heartbeatRunId, ids),
|
|
sql`${environmentLeases.provider} is distinct from 'local'`,
|
|
))).map(row => row.runId));
|
|
const latestByRun = new Map(latest.map(row => [row.runId, row]));
|
|
for (const run of candidates) {
|
|
const event = latestByRun.get(run.id);
|
|
result.set(run.id, event?.eventType === LOCAL_PROCESS_STOPPED ? "already_stopped"
|
|
: event?.eventType !== PROCESS_START_REQUESTED
|
|
? { code: "no_unidentified_launch", message: "This run has no unconfirmed runner launch to stop." }
|
|
: record(event.payload).hostname !== os.hostname()
|
|
? { code: "launch_on_other_host", message: "The runner was launched on another host. Confirm the stop there." }
|
|
: remote.has(run.id)
|
|
? { code: "remote_environment", message: "This run used a remote environment. Its environment cleanup confirms the stop." }
|
|
: null);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function unidentifiedLocalLaunchRefusal(db: Db, run: UnidentifiedLaunchRun): Promise<LaunchRefusal> {
|
|
return (await unidentifiedLocalLaunchRefusals(db, [run])).get(run.id) ?? null;
|
|
}
|
|
|
|
export async function confirmUnidentifiedLocalRunnerStopped(db: Db, input: {
|
|
companyId: string; issueId: string; runId: string; actorId: string;
|
|
}): Promise<{ runId: string; alreadyStopped: boolean }> {
|
|
const refuse = (code: string, message: string) => { throw new UnidentifiedRunnerStopRefusedError(code, message); };
|
|
const [run] = await db.select().from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)));
|
|
if (!run || ![run.issueId, run.nativeIssueId, record(run.contextSnapshot).issueId].includes(input.issueId))
|
|
return refuse("run_not_found", "This run does not belong to the task.");
|
|
const refusal = await unidentifiedLocalLaunchRefusal(db, run);
|
|
if (refusal === "already_stopped") return { runId: run.id, alreadyStopped: true };
|
|
if (refusal) return refuse(refusal.code, refusal.message);
|
|
const { noLocalRunnerProcessForRun } = await import("./native-runtime/native-session-executor.js");
|
|
if (!await noLocalRunnerProcessForRun(run.id))
|
|
return refuse("runner_still_running", "A runner for this run is still running on this host. Stop it first.");
|
|
await db.transaction(async tx => {
|
|
const [locked] = await tx.select().from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.id, run.id))).for("update");
|
|
const current = locked ? await unidentifiedLocalLaunchRefusal(tx as unknown as Db, locked) : null;
|
|
if (!locked || current === "already_stopped" || current !== null || locked.finishedAt?.getTime() !== run.finishedAt?.getTime())
|
|
return refuse("run_changed", "This run changed while its stop was being confirmed. Check it again.");
|
|
await appendHeartbeatRunEvent(tx as unknown as Db, {
|
|
companyId: run.companyId,
|
|
runId: run.id,
|
|
agentId: run.agentId,
|
|
eventType: LOCAL_PROCESS_STOPPED,
|
|
stream: "system",
|
|
level: "info",
|
|
message: "A board operator confirmed that this run's runner and its agent processes have stopped.",
|
|
payload: { processPid: null, processGroupId: null, processStartedAt: null, basis: "operator_attested",
|
|
attestedByUserId: input.actorId },
|
|
});
|
|
await notifyDeliveryWork(tx as unknown as Db, DELIVERY_QUEUES.accounting, run.companyId);
|
|
});
|
|
return { runId: run.id, alreadyStopped: false };
|
|
}
|
|
|
|
const FINISHED_RUN_STATUSES = new Set(["failed", "cancelled", "timed_out", "succeeded", "interrupted"]);
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
/** A settled native run that never had a runner has nothing to stop:
|
|
* - an admission fence (a quarantined prior-session cleanup) refused it before
|
|
* it requested a launch; or
|
|
* - it failed before native selection (for example a refused budget
|
|
* reservation or a failed preparation query). Selection records the run's
|
|
* native issue, native session, runner instance and coordinator, and a
|
|
* runner launch needs them, so without any of them no runner or provider for this run can exist,
|
|
* in any build.
|
|
* Any launch request, identity record or runner event keeps the normal stop
|
|
* proofs. */
|
|
export async function nativeRunNeverLaunched(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "runtimeMode" | "status" | "errorCode" | "processPid" | "processGroupId" |
|
|
"nativeSessionId" | "runnerInstanceId" | "nativeIssueId">): Promise<boolean> {
|
|
if (run.runtimeMode !== "native" || !SETTLED_RUN_STATUSES.includes(run.status) || run.processPid || run.processGroupId) return false;
|
|
const quarantinedBeforeLaunch = run.status === "failed" && run.errorCode === "native_session_cleanup_quarantined";
|
|
if (!quarantinedBeforeLaunch) {
|
|
// A coordinator row requires the run's native issue (foreign key), so the
|
|
// native issue also rules out a coordinator.
|
|
if (run.nativeSessionId || run.runnerInstanceId || run.nativeIssueId) return false;
|
|
}
|
|
const [launch] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
or(isNotNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, ["adapter.invoke", PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED])),
|
|
)).limit(1);
|
|
return !launch;
|
|
}
|
|
|
|
/** Pre-receipt native runs can retain an exact suspended session after their
|
|
* mutable process fields were cleared. This is admission evidence for a new
|
|
* user turn only, never permission to replay the old run or infer its outcomes.
|
|
* The caller must hold the run/controller locks and verify local lease cleanup.
|
|
*/
|
|
export async function hasHistoricalSuspendedNativeSession(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "agentId" | "runtimeMode" | "processPid" | "processGroupId" |
|
|
"nativeSessionId" | "runnerInstanceId" | "nativeIssueId" | "runnerProfileJson">) {
|
|
if (run.runtimeMode !== "native" || run.processPid || run.processGroupId ||
|
|
!run.nativeSessionId || !run.runnerInstanceId || !run.nativeIssueId) return false;
|
|
const [modernProcessEvidence] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
isNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]),
|
|
)).limit(1);
|
|
// A newer launch invalidates an old stop receipt. Never bypass that fence
|
|
// with a suspended file that could belong to the earlier process generation.
|
|
if (modernProcessEvidence) return false;
|
|
const checkpoint = run.runnerProfileJson?.sessionCheckpoint as Record<string, unknown> | undefined;
|
|
if (checkpoint?.providerSessionId != null && (typeof checkpoint.providerSessionId !== "string" ||
|
|
!checkpoint.providerSessionId.trim())) return false;
|
|
const { nativeFailedRunRetryStateIsSafe } = await import("./native-runtime/native-session-executor.js");
|
|
return nativeFailedRunRetryStateIsSafe({
|
|
execution: run.runnerProfileJson?.nativeExecutionInput,
|
|
companyId: run.companyId, issueId: run.nativeIssueId, agentId: run.agentId, runId: run.id,
|
|
nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId,
|
|
processPid: null, processGroupId: null,
|
|
providerSessionId: typeof checkpoint?.sessionId === "string" ? checkpoint.sessionId : null,
|
|
providerBackendSessionId: typeof checkpoint?.providerSessionId === "string" ? checkpoint.providerSessionId : null,
|
|
recoveryMode: "exact_checkpoint_resume", allowVerifiedBackup: false,
|
|
});
|
|
}
|
|
|
|
export async function withReleasedNativeRunProcess<T extends {
|
|
id: string; companyId: string; processPid: number | null; processGroupId: number | null; processStartedAt: Date | null;
|
|
}>(db: Db, run: T): Promise<T> {
|
|
if (run.processPid !== null || run.processGroupId !== null || run.processStartedAt !== null) return run;
|
|
const stopped = await readNativeLocalProcessStop(db, run.companyId, run.id);
|
|
if (!stopped) return run;
|
|
return { ...run, processPid: stopped.processPid, processGroupId: stopped.processGroupId,
|
|
processStartedAt: stopped.processStartedAt ? new Date(stopped.processStartedAt) : null };
|
|
}
|
|
|
|
/** Recover the exact stopped identity after the mutable run fields were cleared. */
|
|
export async function readNativeLocalProcessStop(db: Db, companyId: string, runId: string) {
|
|
const [event] = await db.select({ eventType: heartbeatRunEvents.eventType, payload: heartbeatRunEvents.payload })
|
|
.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);
|
|
const pid = event?.payload?.processPid;
|
|
const group = event?.payload?.processGroupId;
|
|
if (event?.eventType !== LOCAL_PROCESS_STOPPED || typeof pid !== "number" ||
|
|
!Number.isSafeInteger(pid) || pid <= 1 || group !== pid || !absent(pid) || !absent(-pid)) return null;
|
|
const startedAt = event.payload?.processStartedAt;
|
|
return { processPid: pid, processGroupId: pid,
|
|
...(typeof startedAt === "string" ? { processStartedAt: startedAt } : {}) };
|
|
}
|
|
|
|
type SettledProcessRelease = "released" | "pending" | "skipped";
|
|
|
|
export function settledNativeProcessIdentityCondition(db: Db) {
|
|
const identity = db.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, heartbeatRuns.companyId), eq(heartbeatRunEvents.runId, heartbeatRuns.id),
|
|
eq(heartbeatRunEvents.eventType, PROCESS_IDENTITY_RECORDED), isNull(heartbeatRunEvents.sourceEventId),
|
|
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1).as("settled_process_identity");
|
|
return or(isNotNull(heartbeatRuns.processStartedAt), exists(db.select({ id: environmentLeases.id })
|
|
.from(environmentLeases).where(and(eq(environmentLeases.companyId, heartbeatRuns.companyId),
|
|
eq(environmentLeases.heartbeatRunId, heartbeatRuns.id), sql`${environmentLeases.provider} <> 'local'`))),
|
|
and(gt(heartbeatRuns.processPid, 1), eq(heartbeatRuns.processPid, heartbeatRuns.processGroupId),
|
|
exists(db.select({ payload: identity.payload }).from(identity).where(and(
|
|
sql`${identity.payload}->>'targetKind' = 'local'`, sql`${identity.payload}->>'hostname' = ${os.hostname()}`,
|
|
sql`${identity.payload}->>'processPid' = ${heartbeatRuns.processPid}::text`,
|
|
sql`${identity.payload}->>'processGroupId' = ${heartbeatRuns.processGroupId}::text`,
|
|
)))));
|
|
}
|
|
|
|
export async function releaseSettledNativeRunProcess(db: Db, runId: string, options: { onDeferredDeadline?: (at: number) => void } = {}): Promise<SettledProcessRelease> {
|
|
const outcome = await db.transaction(async transaction => {
|
|
const tx = transaction as unknown as Db;
|
|
const [run] = await tx.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, finishedAt: heartbeatRuns.finishedAt,
|
|
processPid: heartbeatRuns.processPid, processGroupId: heartbeatRuns.processGroupId, processStartedAt: heartbeatRuns.processStartedAt,
|
|
hasSessionCheckpoint: sql<boolean>`coalesce(${heartbeatRuns.runnerProfileJson}->'sessionCheckpoint', 'null'::jsonb) <> 'null'::jsonb`,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update").limit(1);
|
|
if (!run || run.runtimeMode !== "native" || !SETTLED_RUN_STATUSES.includes(run.status) || !run.finishedAt ||
|
|
(run.processPid === null && run.processGroupId === null && run.processStartedAt === null)) return "skipped";
|
|
const [coordinator] = await tx.select({ phase: nativeRunFinalizations.phase }).from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, run.id)).limit(1);
|
|
if (coordinator && !SETTLED_COORDINATOR_PHASES.includes(coordinator.phase)) return "skipped";
|
|
if (!run.hasSessionCheckpoint &&
|
|
!(await nativeSessionProviderEvidence(tx, [run.id])).has(run.id)) return "skipped";
|
|
const leases = await tx.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
if (leases.some(lease => lease.provider !== "local")) {
|
|
if (!leases.every(hasRemoteTerminationReceipt)) return "pending";
|
|
} else {
|
|
if (leases.some(lease => !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed")) return "pending";
|
|
const [identity] = await tx.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
eq(heartbeatRunEvents.eventType, PROCESS_IDENTITY_RECORDED), isNull(heartbeatRunEvents.sourceEventId),
|
|
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
|
|
if (identity?.payload?.targetKind !== "local" || identity.payload.hostname !== os.hostname() ||
|
|
identity.payload.processPid !== run.processPid || identity.payload.processGroupId !== run.processGroupId ||
|
|
(run.processStartedAt ? identity.payload.processStartedAt !== run.processStartedAt.toISOString()
|
|
: !Number.isSafeInteger(run.processPid) || run.processPid! <= 1 || run.processGroupId !== run.processPid)) return "skipped";
|
|
if (!(await recordNativeLocalProcessStop(tx, run))) {
|
|
options.onDeferredDeadline?.(Date.now() + 30_000);
|
|
return "pending";
|
|
}
|
|
}
|
|
await notifyLiveRunRecovery(tx, { companyId: run.companyId, runId: run.id, agentId: run.agentId });
|
|
await notifyRetainedNativeSessionCleanup(tx, { companyId: run.companyId, runId: run.id });
|
|
// Local and remote stops both close the run's usage source.
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
|
|
await tx.update(heartbeatRuns).set({
|
|
processPid: null, processGroupId: null, processStartedAt: null, updatedAt: new Date(),
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
return "released" as const;
|
|
});
|
|
if (outcome !== "pending") pendingSettledProcessReleases.delete(runId);
|
|
return outcome;
|
|
}
|
|
|
|
const MAX_SETTLED_PROCESS_RELEASE_ATTEMPTS = 20;
|
|
const pendingSettledProcessReleases = new Map<string, number>();
|
|
|
|
export async function settleNativeRunProcess(db: Db, runId: string) {
|
|
const outcome = await releaseSettledNativeRunProcess(db, runId);
|
|
const attempts = (pendingSettledProcessReleases.get(runId) ?? 0) + 1;
|
|
if (outcome === "pending" && attempts < MAX_SETTLED_PROCESS_RELEASE_ATTEMPTS) {
|
|
pendingSettledProcessReleases.set(runId, attempts);
|
|
} else {
|
|
pendingSettledProcessReleases.delete(runId);
|
|
}
|
|
return outcome;
|
|
}
|
|
|
|
export function pendingSettledNativeRunProcessIds() {
|
|
return [...pendingSettledProcessReleases.keys()];
|
|
}
|
|
|
|
export async function retryPendingSettledNativeRunProcesses(db: Db, input: { skip?: (runId: string) => boolean } = {}) {
|
|
for (const runId of pendingSettledNativeRunProcessIds()) {
|
|
if (input.skip?.(runId)) continue;
|
|
await settleNativeRunProcess(db, runId);
|
|
}
|
|
}
|
|
|
|
export async function discoverSettledNativeRunProcesses(db: Db, input: {
|
|
limit?: number; skip?: (runId: string) => boolean;
|
|
} = {}) {
|
|
const limit = input.limit ?? 50;
|
|
let after: string | null = null;
|
|
let released = 0;
|
|
do {
|
|
const candidates: Array<{ id: string }> = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
inArray(heartbeatRuns.status, SETTLED_RUN_STATUSES),
|
|
isNotNull(heartbeatRuns.finishedAt),
|
|
or(isNotNull(heartbeatRuns.processPid), isNotNull(heartbeatRuns.processGroupId), isNotNull(heartbeatRuns.processStartedAt)),
|
|
...(after ? [gt(heartbeatRuns.id, after)] : []),
|
|
)).orderBy(asc(heartbeatRuns.id)).limit(limit);
|
|
for (const candidate of candidates) {
|
|
if (input.skip?.(candidate.id)) continue;
|
|
if (await settleNativeRunProcess(db, candidate.id) === "released") released += 1;
|
|
}
|
|
after = candidates.length < limit ? null : candidates.at(-1)!.id;
|
|
} while (after);
|
|
return { released };
|
|
}
|