mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A native Codex run sends `turn.start` to its runner, and the runner sends `turn/start` to Codex. The runner marks the start "ambiguous" before it sends the request > - A control-plane restart can kill the runner and Codex while that request is in flight. After the restart, neither journal says whether Codex created the turn, and the resumed thread has no active turn > - The restored runner then failed closed on every same-run resume with "cannot safely recover an ambiguous Codex turn start without an active replacement turn". The answer never reached the agent > - The next run on the task was refused at the runner-state fence (`prior_owner_terminal_state_indeterminate; retirement_refused=journal_commands_unsettled(turn.start.failed)`) before it asked for a runner. Retry after that run then waited forever on `process_identity_missing` for a process that never existed > - This pull request lets the runner prove from Codex's own thread history that the start never created a turn, and then resume and start the turn once. It also lets Retry continue after a run that the runner-state fence refused before launch > - The benefit is that a turn start cut off by a restart continues by itself when Codex never saw it, and a person's Retry is no longer a dead end ## Linked Issues or Issue Description **What happened?** Local restart-chaos wave `w-20261110T033426-s108`. Command: `--env local --adapters codex --workloads Q1,Q2 --faults K1 --triggers answered --tasks 4 --concurrency 2 --seed 108`. Task t03 (Q2): the answer run `92a57f50` resumed after the first restart. A second K1 restart landed 0.25 s after `session.resumed`, while its `turn.start` was in flight. Each same-run resume then failed with `PRP command session.snapshot failed: cannot safely recover an ambiguous Codex turn start without an active replacement turn`. When the prerequisite finished, the next run `0e6aa9d1` failed before launch with `runner_state_identity_mismatch: prior_owner_terminal_state_indeterminate; retirement_refused=journal_commands_unsettled(turn.start.failed)`. The accepted Retry (202) never created a run: the task stayed on `retryWait.reason=process_identity_missing` until the wave timed out (`R3:retry_no_new_run`). **Expected behavior** If Codex never created the turn, the run resumes and starts it once by itself. If that cannot be proven, a person's Retry starts a new run instead of waiting forever. **Steps to reproduce** 1. Run the wave command above on master. 2. Or run `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport-cut-turn-start.test.ts` on master (case "before" fails with the wave error). ## What Changed - Runner (`codex_provider.rs`, `provider_backend.rs`): when the restored provider state has an ambiguous turn start, no active turn before the loss, and the resumed thread has no active turn, the runner reads the newest turn of the thread (`thread/turns/list`, limit 1, metadata only, the same request `read_thread` already sends). If there is no turn, or the newest turn is one Paperclip already settled, Codex never created the turn. The runner clears the ambiguity, emits a `harness.diagnostic` with code `ambiguous_turn_start_not_created`, and restores an idle session. The old `turn.start` stays `execution_indeterminate` and is never executed again. The same-run resume then starts the run's turn once, through the existing "recovered session without an active turn" path. - Any other history (a turn Paperclip never settled, an in-progress turn, an incomplete page, a non-Codex provider) keeps today's fail-closed error. The probe runs only on this recovery path. The normal turn path does no new work before `turn.start`. - Server (`native-local-process-stop.ts`): `nativeRunNeverLaunched` also accepts a failed native run whose error is the runner-state fence refusal `runner_state_identity_mismatch: prior_owner_*` (or `legacy_prior_owner_*`). The executor throws it only while it migrates another run's saved state, before it requests a launch. The existing evidence check still applies: any launch request, identity record, `adapter.invoke` or runner event keeps the run held. Other runner-state errors (for example `authority_indeterminate`) stay held. - Fake Codex (`fake-codex-app-server.rs`): keeps the thread's turn history in a `<state-file>.turns` sidecar, so `thread/turns/list` returns history like real Codex (newest first, honours `limit`). New `--hang-turn-start <file>` holds `turn/start` "before" or "after" the thread records the turn, to model a kill in flight. - Tests: - New real-runnerd test `runnerd-codex-transport-cut-turn-start.test.ts`. A real runner and fake Codex start a turn; both processes are killed while `turn/start` is in flight. "before": the resumed run completes, the journal holds the old `turn.start` (`execution_indeterminate`) and one new completed `turn.start` with the same task text, and Codex saw exactly one more `turn/start`. "after": recovery still fails closed and no new `turn/start` is sent. - Rust: `ambiguous_turn_start_that_never_created_a_turn_recovers_and_starts_once` and `ambiguous_turn_start_that_created_an_unsettled_turn_still_fails_closed`. Both rewrite saved provider state the way a master runner wrote it, so they also cover state saved before this change. - `explicit-native-continuation.test.ts`: five new rows for the runner-state fence. Admitted: refused before launch (with and without a coordinator). Held on `process_identity_missing`: refused after a launch request, refused after a runner event, and a run that failed its own `authority_indeterminate` check. - `docs/durable-recovery.md`: describes both behaviours. ## Verification - Red/green, real runnerd: on master (the staged master runnerd), `runnerd-codex-transport-cut-turn-start.test.ts` "before" fails with `PRP command runner.drain failed: ... cannot safely recover an ambiguous Codex turn start without an active replacement turn`, the wave error. With this change, both cases pass. - Red/green, Rust: without the `provider_backend.rs` change, `cargo test -p paperclip-runner-core --test codex_provider ambiguous_turn_start_that` fails the "never created" test. With it, both pass. The full `cargo test -p paperclip-runner-core` passes. - Red/green, server: without the `native-local-process-stop.ts` change, the two "refused at the prior-owner runner-state fence" rows fail with `process_identity_missing`. With it, all 14 rows pass. - Related suites pass: 11 Runner transport files, 419 tests (`runnerd-codex-transport`, `-attachment-recovery`, `-cold-restore`, `-warm-attachment`, `-release-resume`, `-cut-attach`, `-cut-turn-start`, `-settlement`, `-suspension`, `runnerd-restart-continuation`, `runnerd-saved-continuation`); server `explicit-native-continuation`, `quarantined-task-retry` and `issue-confirm-run-stopped-routes`, 3 files, 470 tests; full `cargo test -p paperclip-runner-core`. - `pnpm --filter @paperclipai/paperclip-runner exec tsc --noEmit` and server `tsc --noEmit` pass. - Pack `contractDigest` is unchanged: `sha256:3164357d…` before and after (`scripts/contracts/contract-fingerprints.mjs`). - Live proof: the wave command above on this branch. Results follow in a comment. **No duplicate provider work.** The runner starts a new turn only when Codex's own history proves the cut-off start created nothing. The old command is never executed again (the runner refuses an `indeterminate` command). A recorded turn that Paperclip never settled keeps the run failed closed. ## Risks - Low-medium risk. The runner change runs only while it restores a session whose saved state has an ambiguous turn start and no active turn. Before this change, that run could never continue. - The proof relies on Codex recording a turn in the thread before it runs anything. If Codex accepted the request but died before it wrote the turn, the runner treats the start as not created and starts the turn once. Codex writes the user turn to the thread when it starts the turn, before it asks the model for output, so no model output or tool call is expected without a recorded turn. - If the thread's newest turn is not in the runner's settled set (for example after a provider identity epoch rollover forgot older identities), the runner keeps failing closed, as today. - **Open decision (not changed here):** when Codex did record the cut-off turn, it may have done work. This pull request keeps the run failed closed and does not replay or adopt that turn. Continuing is a person's decision through Retry or a new message. - The server change trusts the executor's own error text `runner_state_identity_mismatch: prior_owner_*`, together with the absence of every launch and runner event. A run that requested a launch keeps every stop proof. - **Runner release:** the pack `contractDigest` does not change. Local runners use the runnerd built with the app, so the fix applies with this release. - Not covered: the related `authority_indeterminate` variant in the same wave (t04, Q1) is a separate cause; its Retry already worked in that wave. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## 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 #20477, #20482 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
451 lines
27 KiB
TypeScript
451 lines
27 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;
|
|
* - the runner-state fence refused it before it requested a launch because a
|
|
* previous run still owns the conversation's saved runner state and that
|
|
* owner could not be proven finished (`prior_owner_*`). The root belonged
|
|
* to another run, so this run started nothing; 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. */
|
|
/** Thrown only by the executor's runner-state migration, before any launch,
|
|
* when the conversation's saved state names a different (prior) run. */
|
|
const PRIOR_OWNER_FENCE_REFUSAL = /^runner_state_identity_mismatch: (?:legacy_)?prior_owner_/;
|
|
|
|
export async function nativeRunNeverLaunched(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "runtimeMode" | "status" | "errorCode" | "processPid" | "processGroupId" |
|
|
"nativeSessionId" | "runnerInstanceId" | "nativeIssueId" | "error">): Promise<boolean> {
|
|
if (run.runtimeMode !== "native" || !SETTLED_RUN_STATUSES.includes(run.status) || run.processPid || run.processGroupId) return false;
|
|
const refusedBeforeLaunch = run.status === "failed" && (run.errorCode === "native_session_cleanup_quarantined" ||
|
|
PRIOR_OWNER_FENCE_REFUSAL.test(run.error ?? ""));
|
|
if (!refusedBeforeLaunch) {
|
|
// 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 };
|
|
}
|