mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/94/changes/paperclip/94 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip manages AI agents and their work. > - Native agents resume prior provider sessions from saved run checkpoints. > - Resume selection must respect every newer progress barrier. > - The prior query returned large context and profile JSON for up to 32 candidates. > - Most candidates need only process, status, binding, and checkpoint-presence checks. > - This PR selects the same source in one SQL snapshot and returns only its required payloads. > - This reduces database response bytes while preserving session authority and saved task context. ## Linked Issues or Issue Description **What existing behavior does this improve?** Native task/session resume selection in `findNativeSessionResumeRun`. **Current behavior** At baseline `bf3ef12f3a78d0b15a32ad57f7e16520fbf6cd5e`, a resume lookup reads complete context and profile JSON for up to 32 runs, then reads provider-event evidence. A synthetic 15-run history returns about 4.44 MB even though one checkpoint is selected. **Proposed behavior** A bounded CTE finds the first checkpoint or progress barrier. The same SQL statement returns the selected source's native input, checkpoint, tool fingerprint, wake envelope, canonical task brief, and process identity. All fallback barriers and the 32-run limit stay in place. **Reason and benefit** Rejected candidates no longer transfer saved prompts and execution inputs to Node. A single snapshot also prevents a gap between candidate selection and payload hydration. **Breaking changes** None to public APIs or stored history. This private service returns the fields consumed by runtime selection and checkpoint rebinding. It keeps complete required nested values and distinguishes missing brief fields from explicit JSON nulls. **Additional context** Checked `ROADMAP.md`. This is a targeted improvement to existing resume behavior. Public searches for native session resume projections found no duplicate. Related work includes #6573 (durable agent sessions) and #17011 (managed Claude resume opt-in); neither changes this database read. Provider-refusal recovery in #17676 is a separate concern and is unchanged. ## What Changed - Select the newest progress barrier within the existing exact company/agent/task/session and 32-run window in one SQL snapshot. - Return three required profile fields, the full wake envelope, and the saved task's ID, title, and description only for the selected source. - Preserve invalid non-null checkpoint barriers, provider evidence, process authority, terminal-status rules, timestamp precision, and exact string binding checks. - Add real PostgreSQL regression coverage and a reproducible baseline/treatment benchmark. Document the read and benchmark command. ## Verification - `pnpm -r typecheck` passed. `pnpm build` passed. The final server `tsc --noEmit` also passed. The later benchmark-only relocation passed `pnpm check:module-boundaries` and the real PostgreSQL proof; runtime and regression-test source are unchanged. - Related suites: `native-session-resume-projection.test.ts`, `native-session-resume.test.ts`, `heartbeat/retries.test.ts`, and `heartbeat-retry-scheduling.test.ts`: 78 passed and 54 skipped across 3 passed files and 1 skipped file. Embedded PostgreSQL/platform gates caused the skips. Resume orchestration and the 10 new SQL tests ran against isolated PostgreSQL 17.11. The final 10-test projection rerun passed. - New SQL cases cover all seven provider evidence kinds, each process authority field, all terminal statuses, wrong bindings and runtime, invalid checkpoints, null/scalar/array/object JSON, missing versus null brief fields, the 32-row bound, ordering ties, future barriers, submillisecond timestamps, and a change committed before the one selection statement. - `pnpm test:run` was attempted. The host's embedded PostgreSQL startup failed after five attempts. The broad run recorded failures/skips in embedded-database suites and was stopped after that concrete host limitation (exit 143). It is not a local full-suite pass. The full final-head CI matrix passes on `dcde4e3dac2173321d7be2406e7775af8411826a`: 55 successful checks, one neutral check, and two optional Storybook skips. Greptile completed at 5/5 on that exact head, with zero unresolved findings. The initial benchmark-location policy failure was fixed before this final run. - Reproduce the benchmark with `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-native-session-resume-reads.ts --baseline-ref=bf3ef12f3a78d0b15a32ad57f7e16520fbf6cd5e`. The optional `PAPERCLIP_SESSION_RESUME_BENCHMARK_DATABASE_URL` must name an isolated database ending in `_test`. - The harness imports the pinned baseline module, invokes both actual services, captures complete generated SQL and parameters, compares every retained consumer field, and captures `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`. Its incompressible wide fixture keeps a complete 96 KiB native input in the selected result. Raw PostgreSQL text DataRow frames are measured exactly; RowDescription, protocol command metadata, TLS, and TCP are excluded. | Synthetic local case | Baseline DataRow bytes | Treatment DataRow bytes | Reduction | | --- | ---: | ---: | ---: | | 15 runs, one selected checkpoint | 4,441,143 | 104,599 | 97.64% | | 15 unused bootstraps | 4,435,590 | 0 | 100% | | One small selected source | 6,395 | 6,319 | 1.19% | The committed-head benchmark uses two warmups and seven alternating samples per variant. Local round-trip medians and ranges were: | Synthetic local case | Baseline median (range), ms | Treatment median (range), ms | | --- | ---: | ---: | | Wide history | 20.32 (18.63–24.37) | 1.63 (1.40–2.21) | | Unused history | 18.52 (17.63–20.35) | 1.20 (1.13–1.56) | | Small selected source | 1.05 (0.95–1.70) | 0.78 (0.73–1.30) | The small case has overlapping ranges and a 1.19% byte change, so it is neutral. `EXPLAIN` server execution time was higher for the treatment (wide: 0.402 ms versus 0.028 ms across the two baseline statements). `EXPLAIN` does not serialize result rows; the new JSON presence checks do extra server work while avoiding large result transfer. These are local synthetic round-trip measurements, not production latency claims. The final proof ran on `dcde4e3dac2173321d7be2406e7775af8411826a` with no runtime source diff. ## Risks - Selection policy now has a SQL form. Regression tests preserve the pure selector's barrier semantics, including invalid non-null checkpoints and JavaScript Date precision. - Checkpoint-presence checks can still detoast candidate profiles inside PostgreSQL. The change reduces returned bytes; it does not eliminate all database JSON work. - The selected checkpoint, native input, and wake envelope can still be large because consumers require them in full. No new payload cap is introduced. - There is no migration, cache, background polling, ownership lock change, deployment, or merge in this PR. ## Model Used OpenAI GPT-6 through Codex. The host did not expose an exact runtime model ID or context-window size. The agent used reasoning, code execution, real PostgreSQL, and GitHub tools. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
640 lines
23 KiB
TypeScript
640 lines
23 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { and, desc, eq, exists, inArray, isNotNull, ne, not, or, sql } from "drizzle-orm";
|
|
import { heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
|
|
import type {
|
|
NativeExecutionInput,
|
|
PersistedNativeSession,
|
|
} from "../../vendor/paperclip-runner/index.js";
|
|
import { canonicalNativeRuntimeContextDigest, parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
export type NativeToolExecutionTargetKind = "local" | "remote";
|
|
|
|
/** Fence live ACPX owners when shared runtime semantics change, even if the
|
|
* underlying native executable/profile did not change. Undefined preserves
|
|
* the existing configuration digest for other provider transports.
|
|
*/
|
|
export function nativeRuntimeContractForProvider(
|
|
provider: NativeExecutionInput["provider"],
|
|
): string | undefined {
|
|
return provider.kind === "acpx" ? "paperclip.acpx-runtime-contract.v1" : undefined;
|
|
}
|
|
|
|
/**
|
|
* Persisted provider threads retain their dynamic-tool declarations. This
|
|
* fingerprint is part of checkpoint compatibility and must change whenever
|
|
* the server-authorized native tool definitions or advertisement policy
|
|
* changes. The execution target remains part of compatibility because local and
|
|
* remote files use different server-bound readers.
|
|
*/
|
|
export function nativeToolContractFingerprintForTarget(
|
|
executionTargetKind: NativeToolExecutionTargetKind,
|
|
providerKind?: NativeExecutionInput["provider"]["kind"],
|
|
): string {
|
|
return `sha256:${createHash("sha256")
|
|
.update(
|
|
JSON.stringify({
|
|
schema: "paperclip.native-tool-contract.v18",
|
|
executionTargetKind,
|
|
advertisementPolicy: {
|
|
// Direct provider threads retain declarations from thread/start.
|
|
// Keep this explicit so changing a tool from conditional to stable
|
|
// advertisement rotates checkpoints even when its schema is unchanged.
|
|
readCurrentWakeComments: "always_advertised_binding_gated.v2",
|
|
historicalChatAttachments:
|
|
"always_advertised_conversation_binding_gated.v1",
|
|
registerDeliverable: "verified_local_or_remote_workspace.v2",
|
|
readChatAttachment: "always_advertised_run_scope_local_staging.v2",
|
|
structuredHumanInput:
|
|
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
|
|
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
|
|
...(providerKind === "opencode"
|
|
? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" }
|
|
: {}),
|
|
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
|
|
chatTaskTools: "durable_task_binding_and_accepted_origin.v1",
|
|
githubResponses: "bound_working_comment_progress_and_final.v1",
|
|
githubInstructionSkills: "trusted_configuration_native_selection.v1",
|
|
...(executionTargetKind === "remote"
|
|
? { assignedMcpTools: "codex_server_gateway_prp_relay.v1" }
|
|
: {}),
|
|
},
|
|
tools: [
|
|
{ name: "register_deliverable", version: 2 },
|
|
{
|
|
name: "read_current_wake_comments",
|
|
semanticContract: "paperclip.server-current-wake-comments.v1",
|
|
version: 1,
|
|
},
|
|
{
|
|
name: "list_chat_attachments",
|
|
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
|
|
version: 1,
|
|
},
|
|
{
|
|
name: "reuse_chat_attachment",
|
|
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
|
|
version: 1,
|
|
},
|
|
{
|
|
name: "read_chat_attachment",
|
|
semanticContract: "paperclip.server-chat-attachment-read.v1",
|
|
version: 1,
|
|
},
|
|
{ name: "request_human_input", version: 1 },
|
|
],
|
|
}),
|
|
)
|
|
.digest("hex")}`;
|
|
}
|
|
|
|
/** Default local-target fingerprint retained for callers and test fixtures. */
|
|
export const NATIVE_TOOL_CONTRACT_FINGERPRINT =
|
|
nativeToolContractFingerprintForTarget("local");
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
export function isNativeSessionId(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
value,
|
|
)
|
|
);
|
|
}
|
|
|
|
const LEGACY_RETRY_SOURCE_TERMINAL_STATUSES = new Set([
|
|
"succeeded",
|
|
"interrupted",
|
|
"failed",
|
|
"cancelled",
|
|
"timed_out",
|
|
]);
|
|
|
|
export type NativeSessionBootstrapState = {
|
|
processPid: number | null;
|
|
processGroupId: number | null;
|
|
processStartedAt: Date | null;
|
|
runnerProfileJson: unknown;
|
|
};
|
|
|
|
export function isUnusedNativeSessionBootstrap(
|
|
run: NativeSessionBootstrapState,
|
|
hasProviderEvents: boolean,
|
|
): boolean {
|
|
return (
|
|
run.processPid === null &&
|
|
run.processGroupId === null &&
|
|
run.processStartedAt === null &&
|
|
record(run.runnerProfileJson).sessionCheckpoint == null &&
|
|
!hasProviderEvents
|
|
);
|
|
}
|
|
|
|
/** Newest-first rows for one exact task/session; never skip provider progress. */
|
|
export function selectNativeSessionResumeRun<
|
|
T extends NativeSessionBootstrapState & {
|
|
id: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
nativeIssueId: string | null;
|
|
nativeSessionId: string | null;
|
|
runtimeMode: string | null;
|
|
status: string;
|
|
createdAt: Date;
|
|
},
|
|
>(input: {
|
|
runs: readonly T[];
|
|
companyId: string;
|
|
agentId: string;
|
|
issueId: string;
|
|
normalizedSessionId: string;
|
|
currentRunId: string;
|
|
beforeCreatedAt: Date;
|
|
providerEvidenceRunIds: ReadonlySet<string>;
|
|
}): T | null {
|
|
for (const run of input.runs) {
|
|
if (
|
|
run.id === input.currentRunId ||
|
|
run.companyId !== input.companyId ||
|
|
run.agentId !== input.agentId ||
|
|
run.nativeIssueId !== input.issueId ||
|
|
run.nativeSessionId !== input.normalizedSessionId
|
|
)
|
|
continue;
|
|
if (
|
|
run.runtimeMode !== "native" ||
|
|
!LEGACY_RETRY_SOURCE_TERMINAL_STATUSES.has(run.status)
|
|
)
|
|
return null;
|
|
// Even an incompatible checkpoint is a progress barrier. The caller must
|
|
// reject it or start a fresh session, not fall back to an older checkpoint.
|
|
if (record(run.runnerProfileJson).sessionCheckpoint != null)
|
|
return run.createdAt > input.beforeCreatedAt ? null : run;
|
|
if (
|
|
!isUnusedNativeSessionBootstrap(
|
|
run,
|
|
input.providerEvidenceRunIds.has(run.id),
|
|
)
|
|
)
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const NATIVE_SESSION_PROVIDER_EVENT_TYPES = [
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"turn.started",
|
|
"provider.event",
|
|
"provider.rpc_result",
|
|
];
|
|
|
|
export async function nativeSessionProviderEvidence(
|
|
db: Pick<Db, "select">,
|
|
runIds: string[],
|
|
): Promise<Set<string>> {
|
|
if (!runIds.length) return new Set();
|
|
const rows = await db
|
|
.select({ runId: heartbeatRunEvents.runId })
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
inArray(heartbeatRunEvents.runId, runIds),
|
|
inArray(heartbeatRunEvents.eventType, NATIVE_SESSION_PROVIDER_EVENT_TYPES),
|
|
),
|
|
)
|
|
.groupBy(heartbeatRunEvents.runId);
|
|
return new Set(rows.map((row) => row.runId));
|
|
}
|
|
|
|
export async function findNativeSessionResumeRun(
|
|
db: Db,
|
|
input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
issueId: string;
|
|
normalizedSessionId: string;
|
|
currentRunId: string;
|
|
beforeCreatedAt: Date;
|
|
},
|
|
) {
|
|
if (!isNativeSessionId(input.normalizedSessionId)) return null;
|
|
const candidates = db.$with("native_resume_candidates").as(db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
status: heartbeatRuns.status,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRuns.processGroupId,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
hasCheckpoint: sql<boolean>`coalesce(${heartbeatRuns.runnerProfileJson}->'sessionCheckpoint' <> 'null'::jsonb, false)`.as("has_checkpoint"),
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
ne(heartbeatRuns.id, input.currentRunId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId),
|
|
eq(heartbeatRuns.nativeIssueId, input.issueId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
|
|
.limit(32));
|
|
const terminalStatuses = [...LEGACY_RETRY_SOURCE_TERMINAL_STATUSES];
|
|
const barrier = db.$with("native_resume_barrier").as(db
|
|
.select()
|
|
.from(candidates)
|
|
.where(or(
|
|
ne(candidates.runtimeMode, "native"),
|
|
not(inArray(candidates.status, terminalStatuses)),
|
|
candidates.hasCheckpoint,
|
|
isNotNull(candidates.processPid),
|
|
isNotNull(candidates.processGroupId),
|
|
isNotNull(candidates.processStartedAt),
|
|
exists(db.select({ runId: heartbeatRunEvents.runId })
|
|
.from(heartbeatRunEvents)
|
|
.where(and(
|
|
eq(heartbeatRunEvents.runId, candidates.id),
|
|
inArray(heartbeatRunEvents.eventType, NATIVE_SESSION_PROVIDER_EVENT_TYPES),
|
|
))),
|
|
))
|
|
.orderBy(desc(candidates.createdAt), desc(candidates.id))
|
|
.limit(1));
|
|
const [run] = await db.with(candidates, barrier)
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
runnerInstanceId: heartbeatRuns.runnerInstanceId,
|
|
nativeSessionId: heartbeatRuns.nativeSessionId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
status: heartbeatRuns.status,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRuns.processGroupId,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
runnerProfileJson: sql<{
|
|
nativeToolContractFingerprint: unknown;
|
|
nativeExecutionInput: unknown;
|
|
sessionCheckpoint: unknown;
|
|
}>`jsonb_build_object(
|
|
'nativeToolContractFingerprint', ${heartbeatRuns.runnerProfileJson}->'nativeToolContractFingerprint',
|
|
'nativeExecutionInput', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput',
|
|
'sessionCheckpoint', ${heartbeatRuns.runnerProfileJson}->'sessionCheckpoint'
|
|
)`,
|
|
contextSnapshot: sql<{
|
|
paperclipWake: unknown;
|
|
paperclipIssue: unknown;
|
|
}>`jsonb_build_object(
|
|
'paperclipWake', ${heartbeatRuns.contextSnapshot}->'paperclipWake',
|
|
'paperclipIssue', case when jsonb_typeof(${heartbeatRuns.contextSnapshot}->'paperclipIssue') = 'object'
|
|
then (select coalesce(jsonb_object_agg(brief.key, brief.value), '{}'::jsonb)
|
|
from jsonb_each(${heartbeatRuns.contextSnapshot}->'paperclipIssue') as brief
|
|
where brief.key in ('id', 'title', 'description')) else ${heartbeatRuns.contextSnapshot}->'paperclipIssue' end
|
|
)`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(barrier, eq(heartbeatRuns.id, barrier.id))
|
|
.where(and(
|
|
eq(barrier.runtimeMode, "native"),
|
|
inArray(barrier.status, terminalStatuses),
|
|
eq(barrier.hasCheckpoint, true),
|
|
sql`${heartbeatRuns.companyId}::text = ${input.companyId}`,
|
|
sql`${heartbeatRuns.agentId}::text = ${input.agentId}`,
|
|
sql`${heartbeatRuns.nativeIssueId}::text = ${input.issueId}`,
|
|
sql`${heartbeatRuns.nativeSessionId}::text = ${input.normalizedSessionId}`,
|
|
sql`date_trunc('milliseconds', ${barrier.createdAt}) <= ${input.beforeCreatedAt.toISOString()}::timestamptz`,
|
|
));
|
|
return run ?? null;
|
|
}
|
|
|
|
/** A preassigned session id may rotate only before immutable native admission. */
|
|
export function nativeSessionIdForBootstrapPersistence(input: {
|
|
run: NativeSessionBootstrapState & { nativeSessionId: string | null };
|
|
selectedSessionId: string;
|
|
hasProviderEvents: boolean;
|
|
}): string {
|
|
if (input.run.nativeSessionId === input.selectedSessionId)
|
|
return input.selectedSessionId;
|
|
if (
|
|
record(input.run.runnerProfileJson).nativeExecutionInput !== undefined ||
|
|
!isUnusedNativeSessionBootstrap(input.run, input.hasProviderEvents)
|
|
) {
|
|
throw new Error("native_session_bootstrap_identity_conflict");
|
|
}
|
|
return input.selectedSessionId;
|
|
}
|
|
|
|
/** Caller holds the run row lock; recheck authority after waiting for that lock. */
|
|
export async function prepareNativeSessionBootstrapPersistence(
|
|
db: Pick<Db, "select">,
|
|
input: {
|
|
run: NativeSessionBootstrapState & {
|
|
id: string;
|
|
nativeSessionId: string | null;
|
|
};
|
|
selectedSessionId: string;
|
|
execution: NativeExecutionInput;
|
|
restoringCheckpoint: boolean;
|
|
},
|
|
) {
|
|
const hasProviderEvents = (
|
|
await nativeSessionProviderEvidence(db, [input.run.id])
|
|
).has(input.run.id);
|
|
const profile = record(input.run.runnerProfileJson);
|
|
if (
|
|
profile.nativeExecutionInput !== undefined &&
|
|
!isDeepStrictEqual(
|
|
parseNativeExecutionInput(profile.nativeExecutionInput),
|
|
input.execution,
|
|
)
|
|
) {
|
|
throw new Error("native_execution_input_persisted_binding_mismatch");
|
|
}
|
|
if (
|
|
input.restoringCheckpoint &&
|
|
!isUnusedNativeSessionBootstrap(input.run, hasProviderEvents)
|
|
) {
|
|
throw new Error("native_session_bootstrap_identity_conflict");
|
|
}
|
|
return nativeSessionIdForBootstrapPersistence({
|
|
...input,
|
|
hasProviderEvents,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Legacy compatibility is deliberately narrower than ordinary task-session
|
|
* continuation: only a replacement row that never acquired any native process
|
|
* or provider authority may be rebound to a terminal native source. The exact
|
|
* checkpoint/session/workspace/provider binding is validated separately by
|
|
* rebindNativeSessionCheckpoint.
|
|
*/
|
|
export function isUnusedLegacyNativeRetryReplacement(input: {
|
|
replacement: {
|
|
processPid: number | null;
|
|
processGroupId: number | null;
|
|
processStartedAt: Date | null;
|
|
runnerProfileJson: unknown;
|
|
};
|
|
source: {
|
|
runtimeMode: string | null;
|
|
status: string;
|
|
nativeSessionId: string | null;
|
|
} | null;
|
|
hasProviderEvents: boolean;
|
|
}): boolean {
|
|
return Boolean(
|
|
input.source?.runtimeMode === "native" &&
|
|
LEGACY_RETRY_SOURCE_TERMINAL_STATUSES.has(input.source.status) &&
|
|
isNativeSessionId(input.source.nativeSessionId) &&
|
|
isUnusedNativeSessionBootstrap(input.replacement, input.hasProviderEvents),
|
|
);
|
|
}
|
|
|
|
function sameProvider(
|
|
previous: NativeExecutionInput["provider"],
|
|
current: NativeExecutionInput["provider"],
|
|
): boolean {
|
|
return JSON.stringify(previous) === JSON.stringify(current);
|
|
}
|
|
|
|
function nativeCheckpointWorkspaceScope(
|
|
execution: NativeExecutionInput,
|
|
owningRunId: string,
|
|
) {
|
|
// Projectless work uses each heartbeat run id as a placeholder workspace id.
|
|
// Continuity is safe only when both sides independently prove that shape and
|
|
// their complete immutable workspace descriptors match. A real managed
|
|
// workspace instead binds continuity to its durable workspace row id.
|
|
return execution.binding.executionWorkspaceId === owningRunId
|
|
? {
|
|
kind: "transient" as const,
|
|
cwd: execution.workspace.cwd,
|
|
repoUrl: execution.workspace.repoUrl,
|
|
repoRef: execution.workspace.repoRef,
|
|
branchName: execution.workspace.branchName,
|
|
}
|
|
: {
|
|
kind: "managed" as const,
|
|
id: execution.binding.executionWorkspaceId,
|
|
};
|
|
}
|
|
|
|
function sameWorkspaceScope(input: {
|
|
previousExecution: NativeExecutionInput;
|
|
previousRunId: string;
|
|
currentExecution: NativeExecutionInput;
|
|
}): boolean {
|
|
return (
|
|
JSON.stringify(
|
|
nativeCheckpointWorkspaceScope(
|
|
input.previousExecution,
|
|
input.previousRunId,
|
|
),
|
|
) ===
|
|
JSON.stringify(
|
|
nativeCheckpointWorkspaceScope(
|
|
input.currentExecution,
|
|
input.currentExecution.binding.runId,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
/** A resume delta is valid only if the provider checkpoint really can be used. */
|
|
export function buildNativeExecutionWithCheckpoint(input: {
|
|
previousRun:
|
|
Parameters<typeof rebindNativeSessionCheckpoint>[0]["previousRun"] | null;
|
|
normalizedSessionId: string;
|
|
executionTargetKind?: NativeToolExecutionTargetKind;
|
|
toolRefreshOnResume?: boolean;
|
|
refreshTools?: boolean;
|
|
buildExecution: (options: {
|
|
normalizedSessionId: string;
|
|
resumedSession: boolean;
|
|
}) => NativeExecutionInput;
|
|
}): {
|
|
execution: NativeExecutionInput;
|
|
checkpoint: PersistedNativeSession | null;
|
|
normalizedSessionId: string;
|
|
} {
|
|
const execution = input.buildExecution({
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
resumedSession: input.previousRun !== null,
|
|
});
|
|
// A presentation-only upgrade must not rotate a healthy provider session or
|
|
// lose a durable goal. Keep its v4/v5 format until that session naturally ends.
|
|
// All ordinary identity, workspace, provider, tool and recovery checks still apply.
|
|
if (input.previousRun && (execution.schema === "paperclip.native-execution-input.v4" || execution.schema === "paperclip.native-execution-input.v5")) {
|
|
const previousSchema = record(record(input.previousRun.runnerProfileJson).nativeExecutionInput).schema;
|
|
if ((previousSchema === "paperclip.native-execution-input.v4" || previousSchema === "paperclip.native-execution-input.v5") && previousSchema !== execution.schema) {
|
|
const { completionSources: _sources, ...common } = execution as typeof execution & { completionSources?: unknown };
|
|
const retainedFormat = parseNativeExecutionInput({ ...common, schema: previousSchema });
|
|
const retainedCheckpoint = rebindNativeSessionCheckpoint({
|
|
previousRun: input.previousRun,
|
|
currentExecution: retainedFormat,
|
|
executionTargetKind: input.executionTargetKind,
|
|
toolRefreshOnResume: input.toolRefreshOnResume,
|
|
refreshTools: input.refreshTools,
|
|
});
|
|
if (retainedCheckpoint) return { execution: retainedFormat, checkpoint: retainedCheckpoint, normalizedSessionId: input.normalizedSessionId };
|
|
}
|
|
}
|
|
const checkpoint = input.previousRun
|
|
? rebindNativeSessionCheckpoint({
|
|
previousRun: input.previousRun,
|
|
currentExecution: execution,
|
|
executionTargetKind: input.executionTargetKind,
|
|
toolRefreshOnResume: input.toolRefreshOnResume,
|
|
refreshTools: input.refreshTools,
|
|
})
|
|
: null;
|
|
if (checkpoint)
|
|
return {
|
|
execution,
|
|
checkpoint,
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
};
|
|
// Rebuild both task context and wake instructions. Merely rotating the ID
|
|
// leaves a fresh provider with a compact delta and missing task context.
|
|
const normalizedSessionId = randomUUID();
|
|
return {
|
|
execution: input.buildExecution({
|
|
normalizedSessionId,
|
|
resumedSession: false,
|
|
}),
|
|
checkpoint: null,
|
|
normalizedSessionId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rebind a completed prior run's provider checkpoint to a new heartbeat run.
|
|
* The provider/driver session identity is retained, while every per-turn and
|
|
* per-event field is reset so the new run starts one clean turn via resume.
|
|
*/
|
|
export function rebindNativeSessionCheckpoint(input: {
|
|
previousRun: {
|
|
id: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
nativeSessionId: string | null;
|
|
runnerProfileJson: unknown;
|
|
};
|
|
currentExecution: NativeExecutionInput;
|
|
executionTargetKind?: NativeToolExecutionTargetKind;
|
|
toolRefreshOnResume?: boolean;
|
|
refreshTools?: boolean;
|
|
}): PersistedNativeSession | null {
|
|
if (input.refreshTools && input.toolRefreshOnResume !== true) return null;
|
|
const previousProfile = record(input.previousRun.runnerProfileJson);
|
|
if (
|
|
previousProfile.nativeToolContractFingerprint !==
|
|
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind)
|
|
) {
|
|
return null;
|
|
}
|
|
const rawCheckpoint = record(previousProfile.sessionCheckpoint);
|
|
const checkpointIdentity = record(rawCheckpoint.identity);
|
|
const current = input.currentExecution;
|
|
const normalizedSessionId = current.session.normalizedSessionId;
|
|
if (
|
|
!isNativeSessionId(normalizedSessionId) ||
|
|
input.previousRun.companyId !== current.binding.companyId ||
|
|
input.previousRun.agentId !== current.binding.agentId ||
|
|
input.previousRun.nativeSessionId !== normalizedSessionId ||
|
|
typeof rawCheckpoint.sessionId !== "string" ||
|
|
checkpointIdentity.runId !== input.previousRun.id ||
|
|
checkpointIdentity.companyId !== current.binding.companyId ||
|
|
checkpointIdentity.issueId !== current.binding.issueId ||
|
|
checkpointIdentity.agentId !== current.binding.agentId ||
|
|
checkpointIdentity.sessionId !== normalizedSessionId
|
|
)
|
|
return null;
|
|
|
|
let previousExecution: NativeExecutionInput;
|
|
try {
|
|
previousExecution = parseNativeExecutionInput(
|
|
previousProfile.nativeExecutionInput,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (
|
|
previousExecution.binding.runId !== input.previousRun.id ||
|
|
previousExecution.binding.companyId !== current.binding.companyId ||
|
|
previousExecution.binding.issueId !== current.binding.issueId ||
|
|
previousExecution.binding.agentId !== current.binding.agentId ||
|
|
previousExecution.session.normalizedSessionId !== normalizedSessionId ||
|
|
previousExecution.session.driverKind !== current.session.driverKind ||
|
|
!sameWorkspaceScope({
|
|
previousExecution,
|
|
previousRunId: input.previousRun.id,
|
|
currentExecution: current,
|
|
}) ||
|
|
previousExecution.task.workMode !== current.task.workMode ||
|
|
("executionMode" in previousExecution
|
|
? previousExecution.executionMode
|
|
: "default") !==
|
|
("executionMode" in current ? current.executionMode : "default") ||
|
|
!sameProvider(previousExecution.provider, current.provider) ||
|
|
previousExecution.schema !== current.schema ||
|
|
("homeMode" in previousExecution ? previousExecution.homeMode : "isolated") !== ("homeMode" in current ? current.homeMode : "isolated") ||
|
|
("runtimeContext" in previousExecution &&
|
|
"runtimeContext" in current &&
|
|
previousExecution.runtimeContext.aggregateDigest !== current.runtimeContext.aggregateDigest &&
|
|
!(input.toolRefreshOnResume === true &&
|
|
canonicalNativeRuntimeContextDigest({
|
|
...previousExecution.runtimeContext,
|
|
mcp: current.runtimeContext.mcp,
|
|
}) === current.runtimeContext.aggregateDigest))
|
|
)
|
|
return null;
|
|
|
|
const priorSemanticResult = record(rawCheckpoint.semanticResult);
|
|
const priorContinuation = record(priorSemanticResult.continuation);
|
|
const rawGoal = rawCheckpoint.goal;
|
|
const hasUnfinishedGoal =
|
|
rawGoal !== null &&
|
|
rawGoal !== undefined &&
|
|
record(rawGoal).status !== "complete";
|
|
const providerRecoveryPolicy = hasUnfinishedGoal
|
|
? ("same_session_only" as const)
|
|
: !input.refreshTools && priorSemanticResult.reportedWorkDisposition === "yielded" &&
|
|
priorContinuation.kind === "response_wake"
|
|
? ("allow_replacement_after_governed_wait" as const)
|
|
: ("allow_replacement_after_resume_failure" as const);
|
|
|
|
return {
|
|
...(structuredClone(rawCheckpoint) as unknown as PersistedNativeSession),
|
|
identity: {
|
|
runId: current.binding.runId,
|
|
sessionId: normalizedSessionId,
|
|
companyId: current.binding.companyId,
|
|
issueId: current.binding.issueId,
|
|
agentId: current.binding.agentId,
|
|
},
|
|
cursor: null,
|
|
semanticResult: null,
|
|
terminal: null,
|
|
activeTurnId: null,
|
|
terminalTurns: [],
|
|
pendingRuntimeRequests: [],
|
|
// Settlement evidence belongs to the prior heartbeat run, even when the
|
|
// provider session is reused for the user's response.
|
|
governedWait: undefined,
|
|
providerRecoveryPolicy,
|
|
};
|
|
}
|