@tickernelz/paperclip-pro-server 2026.930.6 → 2026.1001.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/build-info.json +1 -1
  2. package/dist/index.js +3 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/services/execution-projection.d.ts.map +1 -1
  5. package/dist/services/execution-projection.js +2 -1
  6. package/dist/services/execution-projection.js.map +1 -1
  7. package/dist/services/execution-recovery-attempt.d.ts +3 -0
  8. package/dist/services/execution-recovery-attempt.d.ts.map +1 -1
  9. package/dist/services/execution-recovery-attempt.js +9 -0
  10. package/dist/services/execution-recovery-attempt.js.map +1 -1
  11. package/dist/services/heartbeat.d.ts +18 -2
  12. package/dist/services/heartbeat.d.ts.map +1 -1
  13. package/dist/services/heartbeat.js +245 -51
  14. package/dist/services/heartbeat.js.map +1 -1
  15. package/dist/services/hot-restart.d.ts +4 -3
  16. package/dist/services/hot-restart.d.ts.map +1 -1
  17. package/dist/services/hot-restart.js +1 -1
  18. package/dist/services/hot-restart.js.map +1 -1
  19. package/dist/services/recovery/service.d.ts +4 -8
  20. package/dist/services/recovery/service.d.ts.map +1 -1
  21. package/dist/services/recovery/service.js +155 -103
  22. package/dist/services/recovery/service.js.map +1 -1
  23. package/package.json +21 -21
  24. package/ui-dist/assets/{Agents.production-C92v0Lx-.js → Agents.production-tjOEQe3l.js} +2 -2
  25. package/ui-dist/assets/{CompanyActivity.production-zCGxSTfT.js → CompanyActivity.production-DeT7xKMQ.js} +1 -1
  26. package/ui-dist/assets/{CompanyExport-ZX12qv9W.js → CompanyExport-BLyfRuJ5.js} +1 -1
  27. package/ui-dist/assets/{CompanySkills.production-ppXT58tQ.js → CompanySkills.production-BQbzHmtn.js} +1 -1
  28. package/ui-dist/assets/{Costs.production-BtTAEXoR.js → Costs.production-5RBWzUGB.js} +1 -1
  29. package/ui-dist/assets/{OrgChart.production-rZs_mwUx.js → OrgChart.production-D_5tQ3tx.js} +1 -1
  30. package/ui-dist/assets/{RoutineDetail.production-B-MVcelq.js → RoutineDetail.production-Dd163Q10.js} +1 -1
  31. package/ui-dist/assets/{Routines.production-BjxyLaNj.js → Routines.production-g9pJjOvz.js} +1 -1
  32. package/ui-dist/assets/{definition-BghzHoPd.js → definition-BtNjkL-Z.js} +1 -1
  33. package/ui-dist/assets/{index-m5QR0hsC.js → index-B_VCIODd.js} +4 -4
  34. package/ui-dist/index.html +1 -1
  35. package/ui-dist/sw.js +1 -1
@@ -24,7 +24,7 @@ import { claimQueuedNativeReviewRun } from "./native-runtime/native-review-dispa
24
24
  import { buildNativeReviewRequest } from "./native-runtime/native-review-prompt.js";
25
25
  import { isGracefulShutdownInterruptedRun, legacyExecutionNeedsReconciliation, terminalizeLegacyExecution, } from "./legacy-execution-recovery.js";
26
26
  import { adapterExecutionControls, captureAdapterStopOwnership, createAdapterExecutionControl, registerAdapterExecutionControl, waitForAdapterStop, } from "./adapter-execution-control.js";
27
- import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
27
+ import { executionFailureRetryCount, processLossContinuationCount, } from "./execution-recovery-attempt.js";
28
28
  import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
29
29
  export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
30
30
  import { buildExecutionContinuation } from "./execution-continuation.js";
@@ -42,6 +42,7 @@ import path from "node:path";
42
42
  import { execFile as execFileCallback } from "node:child_process";
43
43
  import { promisify } from "node:util";
44
44
  import { createHash, randomUUID } from "node:crypto";
45
+ import { setTimeout as delay } from "node:timers/promises";
45
46
  import { and, asc, desc, eq, exists, getTableColumns, gt, gte, inArray, isNull, isNotNull, lt, lte, ne, notInArray, or, sql, } from "drizzle-orm";
46
47
  import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, INSTANCE_DEFAULT_LOCAL_START_WAIT_BYPASS_SEC, INSTANCE_DEFAULT_MAX_CONCURRENT_LOCAL_RUNS, INSTANCE_DEFAULT_MAX_CONCURRENT_LOCAL_STARTS, INSTANCE_LOCAL_START_WAIT_BYPASS_SEC_MAX, INSTANCE_LOCAL_START_WAIT_BYPASS_SEC_MIN, INSTANCE_MAX_CONCURRENT_LOCAL_RUNS_MAX, INSTANCE_MAX_CONCURRENT_LOCAL_RUNS_MIN, INSTANCE_MAX_CONCURRENT_LOCAL_STARTS_MAX, INSTANCE_MAX_CONCURRENT_LOCAL_STARTS_MIN, LOCAL_CLI_ADAPTER_TYPES, CHAT_PROVIDERS, CONNECTION_INTENT_AGENT_GUIDANCE, CONNECTION_RUNTIME_TOOL_NAMES, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, PROVIDER_QUOTA_MONITOR_SERVICE_NAME, envBindingSchema, isToolConnectionAttentionHealth, } from "@tickernelz/paperclip-pro-shared";
47
48
  import { agents, agentConfigRevisions, agentRuntimeState, agentSessionGoalActions, agentTaskSessions, agentWakeupRequests, activityLog, approvals, assets, chatConversations, chatDeliveries, chatEndpoints, chatMessageLinks, companyMemberships, companySkillTestRuns, companySkillVersions, companySkills as companySkillsTable, companies, completionContracts, costEvents, documentAnnotationComments, documentAnnotationThreads, documentRevisions, environmentLeases, issueDocuments, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issueApprovals, issueAttachments, issueComments, issuePlanDecompositions, issueRecoveryActions, issueRelations, issueThreadInteractions, issues, issueWorkProducts, nativeRunFinalizations, projects, projectWorkspaces, routineRevisions, routineRuns, routines, toolMcpGateways, toolMcpGatewayTokens, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolProfileEntries, toolProfiles, workspaceOperations, } from "@tickernelz/paperclip-pro-db";
@@ -323,9 +324,23 @@ const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0;
323
324
  const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure";
324
325
  const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry";
325
326
  const PROCESS_LOST_RETRY_WAKE_REASON = "process_lost_retry";
327
+ const PROCESS_LOSS_RETRY_REASON = "process_lost";
328
+ const PROCESS_LOSS_CONTINUATION_MAX_ATTEMPTS = 2;
329
+ const PROCESS_LOSS_CONTINUATION_DELAY_MS = 30_000;
330
+ const PROCESS_LOSS_ERROR_CODES = {
331
+ orphaned_running_run: true,
332
+ server_shutdown_interrupted: true,
333
+ process_lost: true,
334
+ [DETACHED_PROCESS_ERROR_CODE]: true,
335
+ };
326
336
  function isTransientWorkspaceGitScanCode(code) {
327
337
  return code === WORKSPACE_GIT_SCAN_ERROR_CODES.timeout || code === WORKSPACE_GIT_SCAN_ERROR_CODES.saturated;
328
338
  }
339
+ const ADAPTER_SELF_SIGNALLED_ERROR_CODES = {
340
+ codex_output_inactivity_monitor: true,
341
+ paperclip_mcp_unavailable: true,
342
+ hermes_gateway_cancelled: true,
343
+ };
329
344
  const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
330
345
  export { INTERACTION_CONTINUATION_INFRA_RETRY_REASON, INTERACTION_CONTINUATION_INFRA_WAKE_REASON, };
331
346
  const INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS = 2;
@@ -798,6 +813,15 @@ const nativeSessionResumeDispatchTimers = new Map();
798
813
  // resolveHeartbeatSchedulingSuppression() check and every heartbeatService()
799
814
  // instance see the same drain.
800
815
  let taskDrainState = null;
816
+ let serverShutdownSignal = null;
817
+ const shutdownTerminatedRunIds = new Set();
818
+ export function beginHeartbeatServerShutdown(signal) {
819
+ serverShutdownSignal ??= signal;
820
+ }
821
+ export function resetHeartbeatServerShutdownForTests() {
822
+ serverShutdownSignal = null;
823
+ shutdownTerminatedRunIds.clear();
824
+ }
801
825
  function readTaskDrain(now) {
802
826
  if (taskDrainState &&
803
827
  taskDrainState.expiresAt !== null &&
@@ -3917,6 +3941,7 @@ const SESSION_CONFIG_FINGERPRINT_KEY = "__paperclipConfigFingerprint";
3917
3941
  const SESSION_CONFIG_FINGERPRINT_VERSION_KEY = "__paperclipConfigFingerprintVersion";
3918
3942
  const SESSION_CONFIG_CATEGORIES_KEY = "__paperclipConfigCategories";
3919
3943
  const SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY = "__paperclipConfigCategoryFingerprints";
3944
+ const SESSION_CONFIG_RESUME_FINGERPRINT_KEY = "__paperclipConfigResumeFingerprint";
3920
3945
  const PAPERCLIP_SESSION_METADATA_KEYS = new Set([
3921
3946
  SESSION_AI_CREDENTIAL_IDENTITY_KEY,
3922
3947
  SESSION_CONFIGURED_MODEL_KEY,
@@ -3924,6 +3949,7 @@ const PAPERCLIP_SESSION_METADATA_KEYS = new Set([
3924
3949
  SESSION_CONFIG_FINGERPRINT_VERSION_KEY,
3925
3950
  SESSION_CONFIG_CATEGORIES_KEY,
3926
3951
  SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY,
3952
+ SESSION_CONFIG_RESUME_FINGERPRINT_KEY,
3927
3953
  ]);
3928
3954
  const WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY = "configFingerprint";
3929
3955
  const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES = [
@@ -3948,6 +3974,19 @@ const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES = [
3948
3974
  "environment",
3949
3975
  "realization",
3950
3976
  ];
3977
+ const RESUME_NEUTRAL_ADAPTER_CONFIG_KEYS = [
3978
+ "instructionsBundleMode",
3979
+ "instructionsRootPath",
3980
+ "instructionsEntryFile",
3981
+ "instructionsFilePath",
3982
+ "promptTemplate",
3983
+ "bootstrapPromptTemplate",
3984
+ "paperclipSkillSync",
3985
+ "paperclipRuntimeSkills",
3986
+ "paperclipConnectorSkillDigest",
3987
+ ];
3988
+ const PROCESS_LOSS_RESUME_NEUTRAL_SESSION_CONFIG_CATEGORIES = { instructions: true, runtimeSkills: true };
3989
+ const PROCESS_LOSS_RESUME_TARGET_SESSION_CONFIG_CATEGORIES = { adapter: true, adapterConfig: true };
3951
3990
  /**
3952
3991
  * Projectless native runs bind their immutable envelope to the run id even
3953
3992
  * though no project-scoped execution-workspace row exists. Treat that value
@@ -4118,6 +4157,7 @@ function readConfigFingerprintFromSessionParams(sessionParams) {
4118
4157
  version,
4119
4158
  categories: readConfigCategoriesFromSessionParams(sessionParams),
4120
4159
  categoryFingerprints: parseStoredConfigCategoryFingerprints(sessionParams[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY]),
4160
+ resumeFingerprint: readNonEmptyString(sessionParams[SESSION_CONFIG_RESUME_FINGERPRINT_KEY]),
4121
4161
  };
4122
4162
  }
4123
4163
  function describeEffectiveRunConfigCategories(categories) {
@@ -4366,9 +4406,10 @@ function buildSessionConfigCategoryValues(input) {
4366
4406
  export async function buildEffectiveRunSessionConfigMetadata(input) {
4367
4407
  const secretManifest = input.secretManifest ?? [];
4368
4408
  const instructions = await resolveInstructionsConfigFingerprintMetadata(input.effectiveAdapterConfig);
4409
+ const fingerprintAdapterConfig = managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome);
4369
4410
  const categoryValues = buildSessionConfigCategoryValues({
4370
4411
  adapterType: input.adapterType,
4371
- effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome),
4412
+ effectiveAdapterConfig: fingerprintAdapterConfig,
4372
4413
  agentRuntimeConfig: input.agentRuntimeConfig,
4373
4414
  instructions,
4374
4415
  issueOverrides: input.issueOverrides,
@@ -4391,11 +4432,26 @@ export async function buildEffectiveRunSessionConfigMetadata(input) {
4391
4432
  subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES,
4392
4433
  secretManifest,
4393
4434
  });
4435
+ const resumeAdapterConfig = { ...fingerprintAdapterConfig };
4436
+ for (const key of RESUME_NEUTRAL_ADAPTER_CONFIG_KEYS)
4437
+ delete resumeAdapterConfig[key];
4438
+ const { resume: resumeFingerprint } = createEffectiveRunConfigSubcategoryFingerprints({
4439
+ category: "session",
4440
+ value: {
4441
+ resume: {
4442
+ adapterType: input.adapterType,
4443
+ adapterConfig: resumeAdapterConfig,
4444
+ },
4445
+ },
4446
+ subcategories: ["resume"],
4447
+ secretManifest,
4448
+ });
4394
4449
  return {
4395
4450
  version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
4396
4451
  fingerprint: fingerprints.sessionFingerprint.fingerprint,
4397
4452
  categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES],
4398
4453
  categoryFingerprints,
4454
+ resumeFingerprint,
4399
4455
  fingerprints,
4400
4456
  };
4401
4457
  }
@@ -4609,6 +4665,8 @@ function attachPaperclipSessionMetadataToSessionParams(sessionParams, configured
4609
4665
  next[SESSION_CONFIG_CATEGORIES_KEY] = configMetadata.categories;
4610
4666
  next[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY] =
4611
4667
  configMetadata.categoryFingerprints;
4668
+ next[SESSION_CONFIG_RESUME_FINGERPRINT_KEY] =
4669
+ configMetadata.resumeFingerprint;
4612
4670
  }
4613
4671
  return next;
4614
4672
  }
@@ -4638,6 +4696,17 @@ export function stripPaperclipSessionMetadataFromSessionParams(sessionParams) {
4638
4696
  }
4639
4697
  return next;
4640
4698
  }
4699
+ function isProcessLossContinuationRun(run, contextSnapshot) {
4700
+ return (run.scheduledRetryReason === PROCESS_LOSS_RETRY_REASON ||
4701
+ readNonEmptyString(contextSnapshot?.wakeReason) ===
4702
+ PROCESS_LOST_RETRY_WAKE_REASON);
4703
+ }
4704
+ function processLossContinuationCanResumeAcross(input) {
4705
+ const resumeTargetUnchanged = input.storedResumeFingerprint === input.nextResumeFingerprint;
4706
+ return input.changedCategories.every((category) => PROCESS_LOSS_RESUME_NEUTRAL_SESSION_CONFIG_CATEGORIES[category] === true ||
4707
+ (resumeTargetUnchanged &&
4708
+ PROCESS_LOSS_RESUME_TARGET_SESSION_CONFIG_CATEGORIES[category] === true));
4709
+ }
4641
4710
  export function resolveTaskSessionConfigFreshness(input) {
4642
4711
  if (!input.hasTaskSession) {
4643
4712
  return {
@@ -4675,7 +4744,14 @@ export function resolveTaskSessionConfigFreshness(input) {
4675
4744
  previous: storedConfig.categoryFingerprints,
4676
4745
  next: input.configMetadata.categoryFingerprints,
4677
4746
  });
4678
- reasons.push(`effective run configuration changed: ${describeEffectiveRunConfigCategories(changedCategories)}`);
4747
+ if (!input.processLossContinuation ||
4748
+ !processLossContinuationCanResumeAcross({
4749
+ changedCategories,
4750
+ storedResumeFingerprint: storedConfig.resumeFingerprint,
4751
+ nextResumeFingerprint: input.configMetadata.resumeFingerprint,
4752
+ })) {
4753
+ reasons.push(`effective run configuration changed: ${describeEffectiveRunConfigCategories(changedCategories)}`);
4754
+ }
4679
4755
  }
4680
4756
  }
4681
4757
  if (input.wakeResetReason)
@@ -6257,18 +6333,20 @@ export function resolveHeartbeatSchedulingSuppression(env = process.env, overrid
6257
6333
  isTruthyRuntimeEnvValue(env.PAPERCLIP_RESTORE_IN_PROGRESS)) {
6258
6334
  return { suppressed: true, reason: "database_restore_in_progress" };
6259
6335
  }
6336
+ if (serverShutdownSignal !== null) {
6337
+ return { suppressed: true, reason: "server_shutdown" };
6338
+ }
6260
6339
  if (readTaskDrain(new Date()) !== null) {
6261
6340
  return { suppressed: true, reason: "task_drain" };
6262
6341
  }
6263
6342
  return { suppressed: false, reason: null };
6264
6343
  }
6265
6344
  export function heartbeatService(db, options = {}) {
6266
- let shutdownInProgress = false;
6345
+ const runtimeEnv = options.runtimeEnv ?? process.env;
6267
6346
  const instanceSettings = instanceSettingsService(db);
6268
6347
  const getCurrentUserRedactionOptions = async () => ({
6269
6348
  enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
6270
6349
  });
6271
- const runtimeEnv = options.runtimeEnv ?? process.env;
6272
6350
  const inWorktreeRuntime = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE);
6273
6351
  // Preview worktree instances suppress the run engine by default. Users can lift
6274
6352
  // that per-worktree via the `enableWorktreeRunExecution` experimental setting
@@ -6357,14 +6435,14 @@ export function heartbeatService(db, options = {}) {
6357
6435
  const agent = await getAgent(run.agentId);
6358
6436
  if (!agent || agent.companyId !== run.companyId)
6359
6437
  return null;
6438
+ if (PROCESS_LOSS_ERROR_CODES[run.errorCode ?? ""])
6439
+ return enqueueProcessLossRetry(run, agent, new Date());
6360
6440
  const result = await scheduleBoundedRetryForRun(run, agent);
6361
6441
  return result.outcome === "scheduled" ? result.run : null;
6362
6442
  },
6363
- // Mirrors scheduleBoundedRetryForRun's transient budget check: a failed
6364
- // or interrupted run that has already consumed every bounded transient
6365
- // attempt cannot be retried again through this lane.
6366
- transientRetryBudgetSpent: (run) => executionFailureRetryCount(run) >=
6367
- BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS,
6443
+ retryBudgetSpent: (run) => PROCESS_LOSS_ERROR_CODES[run.errorCode ?? ""]
6444
+ ? processLossContinuationCount(run) >= PROCESS_LOSS_CONTINUATION_MAX_ATTEMPTS
6445
+ : executionFailureRetryCount(run) >= BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS,
6368
6446
  continueProcessGoneRun,
6369
6447
  });
6370
6448
  const runDispatch = createRunDispatch(db, {
@@ -6547,10 +6625,15 @@ export function heartbeatService(db, options = {}) {
6547
6625
  const [source] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, effect.companyId), eq(heartbeatRuns.id, effect.runId)));
6548
6626
  const agent = source ? await getAgent(source.agentId) : null;
6549
6627
  if (source && agent && agent.companyId === source.companyId) {
6550
- await scheduleBoundedRetryForRun(source, agent, effect.reviewParticipant ? {
6551
- retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
6552
- wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
6553
- } : undefined);
6628
+ if (PROCESS_LOSS_ERROR_CODES[source.errorCode ?? ""]) {
6629
+ await enqueueProcessLossRetry(source, agent, new Date());
6630
+ }
6631
+ else {
6632
+ await scheduleBoundedRetryForRun(source, agent, effect.reviewParticipant ? {
6633
+ retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
6634
+ wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
6635
+ } : undefined);
6636
+ }
6554
6637
  }
6555
6638
  }
6556
6639
  else if (effect.kind === "run_queued") {
@@ -9970,15 +10053,18 @@ export function heartbeatService(db, options = {}) {
9970
10053
  return { outcome: "retry_exhausted", queuedRun: null };
9971
10054
  }
9972
10055
  async function enqueueProcessLossRetry(run, agent, now, opts) {
9973
- // Native sessions have their own fenced same-run controller. Legacy
9974
- // bootstrap recovery shares the durable delay and incident counter with
9975
- // transient retries; process loss must not open a second retry budget.
9976
10056
  if (run.runtimeMode === "native")
9977
10057
  return null;
9978
10058
  if (!isGracefulShutdownInterruptedRun(run) &&
9979
10059
  legacyExecutionNeedsReconciliation(run))
9980
10060
  return null;
9981
- const scheduled = await scheduleBoundedRetryForRun(run, agent, { now, wakeReason: opts?.wakeReason });
10061
+ const scheduled = await scheduleBoundedRetryForRun(run, agent, {
10062
+ now,
10063
+ wakeReason: opts?.wakeReason,
10064
+ retryReason: PROCESS_LOSS_RETRY_REASON,
10065
+ maxAttempts: PROCESS_LOSS_CONTINUATION_MAX_ATTEMPTS,
10066
+ delayMs: PROCESS_LOSS_CONTINUATION_DELAY_MS,
10067
+ });
9982
10068
  return scheduled.outcome === "scheduled" ? scheduled.run : null;
9983
10069
  }
9984
10070
  async function continueProcessGoneRun(run) {
@@ -10061,7 +10147,7 @@ export function heartbeatService(db, options = {}) {
10061
10147
  return (readNonEmptyString(parseObject(input.adapterConfig).engine) !== "cli");
10062
10148
  }
10063
10149
  async function prepareHotRestartShutdown(signal, now = new Date()) {
10064
- shutdownInProgress = true;
10150
+ beginHeartbeatServerShutdown(signal);
10065
10151
  const idleSessions = await closeIdleWarmNativeSessionsForRestart();
10066
10152
  if (idleSessions.failed > 0) {
10067
10153
  logger.warn({ idleSessions }, "idle native sessions could not checkpoint before controller shutdown");
@@ -10112,6 +10198,25 @@ export function heartbeatService(db, options = {}) {
10112
10198
  ...intent,
10113
10199
  previousServerVersion: intent.previousServerVersion ?? serverVersion,
10114
10200
  };
10201
+ if (process.platform === "linux" && runtimeEnv.PAPERCLIP_SERVICE_MANAGED === "1") {
10202
+ const drainRunIds = snapshotRuns.map((run) => run.runId);
10203
+ await writeHotRestartShutdownSnapshot({
10204
+ intent: intentWithVersion,
10205
+ signal,
10206
+ activeRuns: snapshotRuns,
10207
+ drainReason: "service_stop_kills_children",
10208
+ drainRunIds,
10209
+ capturedAt: now,
10210
+ });
10211
+ logger.warn({ signal, previousServerPid: intent.previousServerPid, drainRunIds }, "systemd stops every process left in the service cgroup; draining live runs into process-loss continuations instead of adopting them");
10212
+ return {
10213
+ mode: "service_drain_required",
10214
+ skipDrain: false,
10215
+ activeRunIds: drainRunIds,
10216
+ drainRunIds,
10217
+ drainReason: "service_stop_kills_children",
10218
+ };
10219
+ }
10115
10220
  const serverStdioRuns = activeRuns.filter(isServerStdioBoundHotRestartRun);
10116
10221
  if (serverStdioRuns.length > 0) {
10117
10222
  const activeServerStdioRunIds = serverStdioRuns.map(({ run }) => run.id);
@@ -10288,18 +10393,15 @@ export function heartbeatService(db, options = {}) {
10288
10393
  classify(candidate, "finalized_while_down", `run_status_${run.status}`, patch);
10289
10394
  continue;
10290
10395
  }
10291
- const hasSelectiveAcpDrain = intent.drainReason === "active_acp_run" &&
10396
+ const hasSelectiveDrain = (intent.drainReason === "active_acp_run" ||
10397
+ intent.drainReason === "service_stop_kills_children") &&
10292
10398
  (intent.drainRunIds?.length ?? 0) > 0;
10293
- if (hasSelectiveAcpDrain &&
10399
+ if (hasSelectiveDrain &&
10294
10400
  intent.drainRunIds?.includes(candidate.runId)) {
10295
- // A selective ACP drain is expected to persist a terminal row before
10296
- // the new server starts. If the process was terminated but that write
10297
- // failed, surface the run as lost instead of hiding it as an expected
10298
- // drain skip.
10299
10401
  classify(candidate, "lost", "selective_drain_not_finalized", patch);
10300
10402
  continue;
10301
10403
  }
10302
- if (intent.drainRequired && !hasSelectiveAcpDrain) {
10404
+ if (intent.drainRequired && !hasSelectiveDrain) {
10303
10405
  classify(candidate, "skipped", "drain_required", patch);
10304
10406
  continue;
10305
10407
  }
@@ -10527,6 +10629,7 @@ export function heartbeatService(db, options = {}) {
10527
10629
  };
10528
10630
  }
10529
10631
  async function drainRunningRunsForShutdown(signal, now = new Date(), runIds = null) {
10632
+ beginHeartbeatServerShutdown(signal);
10530
10633
  const selectedRunIds = runIds ? [...new Set(runIds)] : null;
10531
10634
  if (selectedRunIds?.length === 0) {
10532
10635
  return {
@@ -10549,6 +10652,27 @@ export function heartbeatService(db, options = {}) {
10549
10652
  const interruptedRunIds = [];
10550
10653
  const retryRunIds = [];
10551
10654
  const restartSuspendedRunIds = [];
10655
+ const legacyTerminations = new Map();
10656
+ for (const { run } of activeRuns) {
10657
+ const running = runningProcesses.get(run.id);
10658
+ if (run.runtimeMode === "native" ||
10659
+ !running ||
10660
+ (run.controllerBootId && run.controllerBootId !== legacyControllerBootId) ||
10661
+ isNativeRunnerOwnershipHeld(run))
10662
+ continue;
10663
+ shutdownTerminatedRunIds.add(run.id);
10664
+ legacyTerminations.set(run.id, terminateHeartbeatRunProcess({
10665
+ pid: running.child.pid,
10666
+ processGroupId: running.processGroupId,
10667
+ graceMs: Math.max(1, running.graceSec) * 1000,
10668
+ }).then(() => null, (error) => error ?? new Error("Run process termination failed")));
10669
+ }
10670
+ await Promise.all(legacyTerminations.values());
10671
+ const executorDeadline = Date.now() + (options.shutdownExecutorSettleTimeoutMs ?? 15_000);
10672
+ while ([...legacyTerminations.keys()].some((runId) => activeRunExecutions.has(runId)) &&
10673
+ Date.now() < executorDeadline) {
10674
+ await delay(50);
10675
+ }
10552
10676
  for (const { run, agent } of activeRuns) {
10553
10677
  // Shutdown owns only this boot's legacy executions. Expired foreign
10554
10678
  // owners belong to the reaper, not another container's drain.
@@ -10622,7 +10746,12 @@ export function heartbeatService(db, options = {}) {
10622
10746
  runtimeMode: run.runtimeMode,
10623
10747
  });
10624
10748
  }
10625
- if (running) {
10749
+ if (legacyTerminations.has(run.id)) {
10750
+ const terminationError = await legacyTerminations.get(run.id);
10751
+ if (terminationError)
10752
+ throw terminationError;
10753
+ }
10754
+ else if (running) {
10626
10755
  await terminateHeartbeatRunProcess({
10627
10756
  pid: running.child.pid,
10628
10757
  processGroupId: running.processGroupId,
@@ -10648,8 +10777,20 @@ export function heartbeatService(db, options = {}) {
10648
10777
  errorMessage: message,
10649
10778
  }),
10650
10779
  });
10651
- if (!interruptedStatus.updated || !interruptedStatus.run)
10780
+ if (!interruptedStatus.updated || !interruptedStatus.run) {
10781
+ if (interruptedStatus.run &&
10782
+ isGracefulShutdownInterruptedRun(interruptedStatus.run)) {
10783
+ interruptedRunIds.push(run.id);
10784
+ const [executorRetry] = await db
10785
+ .select({ id: heartbeatRuns.id })
10786
+ .from(heartbeatRuns)
10787
+ .where(eq(heartbeatRuns.retryOfRunId, run.id))
10788
+ .limit(1);
10789
+ if (executorRetry)
10790
+ retryRunIds.push(executorRetry.id);
10791
+ }
10652
10792
  continue;
10793
+ }
10653
10794
  let interrupted = interruptedStatus.run;
10654
10795
  await setWakeupStatus(run.wakeupRequestId, "cancelled", {
10655
10796
  finishedAt: now,
@@ -10708,11 +10849,13 @@ export function heartbeatService(db, options = {}) {
10708
10849
  const retryReason = opts?.retryReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON;
10709
10850
  const wakeReason = opts?.wakeReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON;
10710
10851
  const maxAttempts = Math.max(0, Math.floor(opts?.maxAttempts ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS));
10711
- const nextAttempt = (retryReason === WORKSPACE_BUSY_RETRY_REASON ||
10712
- retryReason === AI_CONNECTION_BUSY_RETRY_REASON ||
10713
- retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
10714
- ? (run.scheduledRetryAttempt ?? 0)
10715
- : executionFailureRetryCount(run)) + 1;
10852
+ const nextAttempt = retryReason === PROCESS_LOSS_RETRY_REASON
10853
+ ? processLossContinuationCount(run) + 1
10854
+ : (retryReason === WORKSPACE_BUSY_RETRY_REASON ||
10855
+ retryReason === AI_CONNECTION_BUSY_RETRY_REASON ||
10856
+ retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
10857
+ ? (run.scheduledRetryAttempt ?? 0)
10858
+ : executionFailureRetryCount(run)) + 1;
10716
10859
  const computedBaseSchedule = opts?.delayMs != null
10717
10860
  ? nextAttempt <= maxAttempts
10718
10861
  ? {
@@ -10740,16 +10883,19 @@ export function heartbeatService(db, options = {}) {
10740
10883
  const contextSnapshot = parseObject(run.contextSnapshot);
10741
10884
  const issueId = readNonEmptyString(contextSnapshot.issueId);
10742
10885
  if (!baseSchedule) {
10886
+ const spentAttempts = retryReason === PROCESS_LOSS_RETRY_REASON
10887
+ ? nextAttempt - 1
10888
+ : (run.scheduledRetryAttempt ?? 0);
10743
10889
  const exhaustion = {
10744
10890
  retryReason,
10745
- scheduledRetryAttempt: run.scheduledRetryAttempt ?? 0,
10891
+ scheduledRetryAttempt: spentAttempts,
10746
10892
  maxAttempts,
10747
10893
  };
10748
10894
  await appendRunEvent(run, {
10749
10895
  eventType: "lifecycle",
10750
10896
  stream: "system",
10751
10897
  level: "warn",
10752
- message: `Bounded retry exhausted after ${run.scheduledRetryAttempt ?? 0} scheduled attempts; no further automatic retry will be queued`,
10898
+ message: `Bounded retry exhausted after ${spentAttempts} scheduled attempts; no further automatic retry will be queued`,
10753
10899
  payload: exhaustion,
10754
10900
  retryExhaustion: exhaustion,
10755
10901
  });
@@ -10908,6 +11054,14 @@ export function heartbeatService(db, options = {}) {
10908
11054
  ...(retryReason === AI_CONNECTION_BUSY_RETRY_REASON
10909
11055
  ? { failureRetriesBeforeAiConnectionWait: executionFailureRetryCount(run) }
10910
11056
  : {}),
11057
+ ...(retryReason === PROCESS_LOSS_RETRY_REASON
11058
+ ? {
11059
+ failureRetriesBeforeProcessLoss: executionFailureRetryCount(run),
11060
+ processLossContinuations: nextAttempt,
11061
+ }
11062
+ : retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
11063
+ ? { processLossContinuations: undefined }
11064
+ : {}),
10911
11065
  ...(shouldQuarantineWorkspaceForRetry
10912
11066
  ? {
10913
11067
  workspaceValidationRecovery: {
@@ -15595,6 +15749,7 @@ export function heartbeatService(db, options = {}) {
15595
15749
  taskSessionParams: taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
15596
15750
  configMetadata: sessionConfigMetadata,
15597
15751
  wakeResetReason: wakeSessionResetReason,
15752
+ processLossContinuation: isProcessLossContinuationRun(run, context),
15598
15753
  preserveLegacySessionWithoutConfigMetadata: acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision,
15599
15754
  });
15600
15755
  const resetTaskSession = shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
@@ -18272,7 +18427,12 @@ export function heartbeatService(db, options = {}) {
18272
18427
  failedProcessRunCancellations.get(run.id);
18273
18428
  await processCancellation?.settled;
18274
18429
  let outcome;
18430
+ let processLossErrorCode = null;
18275
18431
  const latestRun = await getRun(run.id);
18432
+ const latestResult = parseObject(latestRun?.resultJson);
18433
+ const paperclipCancellationRecorded = Boolean(processCancellation) ||
18434
+ Boolean(latestResult.executionCancellation) ||
18435
+ Boolean(latestResult.startupCancellation);
18276
18436
  if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
18277
18437
  outcome = latestRun.status;
18278
18438
  }
@@ -18288,6 +18448,11 @@ export function heartbeatService(db, options = {}) {
18288
18448
  ? "cancelled"
18289
18449
  : "failed";
18290
18450
  }
18451
+ else if (shutdownTerminatedRunIds.has(run.id) &&
18452
+ !paperclipCancellationRecorded) {
18453
+ outcome = "interrupted";
18454
+ processLossErrorCode = "server_shutdown_interrupted";
18455
+ }
18291
18456
  else if (adapterResult.timedOut) {
18292
18457
  outcome = "timed_out";
18293
18458
  }
@@ -18299,6 +18464,20 @@ export function heartbeatService(db, options = {}) {
18299
18464
  }
18300
18465
  else {
18301
18466
  outcome = "failed";
18467
+ if (!paperclipCancellationRecorded &&
18468
+ (adapterResult.signal === "SIGTERM" ||
18469
+ adapterResult.signal === "SIGINT" ||
18470
+ adapterResult.exitCode === 143 ||
18471
+ adapterResult.exitCode === 130) &&
18472
+ !ADAPTER_SELF_SIGNALLED_ERROR_CODES[adapterResult.errorCode ?? ""] &&
18473
+ !hasUnmanagedBackgroundTaskEvidence(parseObject(adapterResult.resultJson))) {
18474
+ processLossErrorCode =
18475
+ serverShutdownSignal === null
18476
+ ? "process_lost"
18477
+ : "server_shutdown_interrupted";
18478
+ if (serverShutdownSignal !== null)
18479
+ outcome = "interrupted";
18480
+ }
18302
18481
  }
18303
18482
  const nextSessionState = resolveNextSessionState({
18304
18483
  adapterType: agent.adapterType,
@@ -18322,18 +18501,21 @@ export function heartbeatService(db, options = {}) {
18322
18501
  ? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
18323
18502
  : outcome === "succeeded"
18324
18503
  ? null
18325
- : redactCurrentUserText(adapterResult.errorMessage ??
18326
- (outcome === "timed_out" ? "Timed out" : "Adapter failed"), currentUserRedactionOptions);
18504
+ : outcome === "interrupted" && serverShutdownSignal !== null
18505
+ ? `Interrupted by graceful server shutdown (${serverShutdownSignal})`
18506
+ : redactCurrentUserText(adapterResult.errorMessage ??
18507
+ (outcome === "timed_out" ? "Timed out" : "Adapter failed"), currentUserRedactionOptions);
18327
18508
  const recordedResponsibleUserDenialCode = normalizeResponsibleUserDenialCode(latestRun?.errorCode);
18328
- const runErrorCode = outcome === "timed_out"
18329
- ? "timeout"
18330
- : outcome === "cancelled"
18331
- ? (latestRun?.errorCode ?? "cancelled")
18332
- : outcome === "failed"
18333
- ? (adapterResult.errorCode ??
18334
- recordedResponsibleUserDenialCode ??
18335
- "adapter_failed")
18336
- : null;
18509
+ const runErrorCode = processLossErrorCode ??
18510
+ (outcome === "timed_out"
18511
+ ? "timeout"
18512
+ : outcome === "cancelled"
18513
+ ? (latestRun?.errorCode ?? "cancelled")
18514
+ : outcome === "failed"
18515
+ ? (adapterResult.errorCode ??
18516
+ recordedResponsibleUserDenialCode ??
18517
+ "adapter_failed")
18518
+ : null);
18337
18519
  let logSummary = null;
18338
18520
  if (handle) {
18339
18521
  logSummary = await runLogStore.finalize(handle);
@@ -18358,7 +18540,9 @@ export function heartbeatService(db, options = {}) {
18358
18540
  ? "cancelled"
18359
18541
  : outcome === "timed_out"
18360
18542
  ? "timed_out"
18361
- : "failed";
18543
+ : outcome === "interrupted"
18544
+ ? "interrupted"
18545
+ : "failed";
18362
18546
  const cacheAdjustedCostUsd = resolveCacheAdjustedCostUsd(adapterResult);
18363
18547
  const usageJson = normalizedUsage ||
18364
18548
  adapterResult.costUsd != null ||
@@ -18482,7 +18666,11 @@ export function heartbeatService(db, options = {}) {
18482
18666
  persistedRun =
18483
18667
  (await classifyAndPersistRunLiveness(persistedRun, persistedResultJson)) ?? persistedRun;
18484
18668
  }
18485
- await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, {
18669
+ await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded"
18670
+ ? "completed"
18671
+ : outcome === "interrupted"
18672
+ ? "cancelled"
18673
+ : status, {
18486
18674
  finishedAt: new Date(),
18487
18675
  error: runErrorMessage,
18488
18676
  });
@@ -18624,7 +18812,10 @@ export function heartbeatService(db, options = {}) {
18624
18812
  catch (err) {
18625
18813
  await onLog("stderr", `[paperclip] Failed to resolve run presentation: ${err instanceof Error ? err.message : String(err)}\n`);
18626
18814
  }
18627
- if (outcome === "failed" && isMaxTurnExhaustionRun(livenessRun)) {
18815
+ if (processLossErrorCode) {
18816
+ await enqueueProcessLossRetry(livenessRun, agent, new Date());
18817
+ }
18818
+ else if (outcome === "failed" && isMaxTurnExhaustionRun(livenessRun)) {
18628
18819
  const policy = parseMaxTurnContinuationPolicy(agent);
18629
18820
  if (policy.enabled && policy.maxAttempts > 0) {
18630
18821
  await scheduleBoundedRetryForRun(livenessRun, agent, {
@@ -19340,6 +19531,7 @@ export function heartbeatService(db, options = {}) {
19340
19531
  finally {
19341
19532
  controllerLease.stop();
19342
19533
  activeRunExecutions.delete(run.id);
19534
+ shutdownTerminatedRunIds.delete(run.id);
19343
19535
  clearLocalCliRunStarting(run.id);
19344
19536
  // A failed owned Stop remains visible until this exact executor settles,
19345
19537
  // including a graceful exit result arriving after the cancellation error.
@@ -19363,7 +19555,7 @@ export function heartbeatService(db, options = {}) {
19363
19555
  }
19364
19556
  if (!nativeSessionResumeScheduled &&
19365
19557
  !nativeWorkspaceFinalizeScheduled &&
19366
- !shutdownInProgress) {
19558
+ serverShutdownSignal === null) {
19367
19559
  if (latestRun)
19368
19560
  await resumeRemoteStopComments(latestRun).catch(err => {
19369
19561
  logger.warn({ err, runId: run.id }, "failed to resume user messages after remote Stop");
@@ -19621,7 +19813,8 @@ export function heartbeatService(db, options = {}) {
19621
19813
  // continuation wake landed mid-drain left its issue in `todo` with no
19622
19814
  // run and no path until a person noticed.
19623
19815
  if (schedulingSuppression.suppressed &&
19624
- schedulingSuppression.reason !== "task_drain") {
19816
+ schedulingSuppression.reason !== "task_drain" &&
19817
+ schedulingSuppression.reason !== "server_shutdown") {
19625
19818
  await writeSkippedHeartbeatRequest("heartbeat.scheduling_suppressed", {
19626
19819
  reason: schedulingSuppression.reason,
19627
19820
  });
@@ -21960,6 +22153,7 @@ export function heartbeatService(db, options = {}) {
21960
22153
  dispatchPendingNativeStatusWakeups,
21961
22154
  triggerIssueMonitor,
21962
22155
  reportRunActivity: clearDetachedRunWarning,
22156
+ beginServerShutdown: beginHeartbeatServerShutdown,
21963
22157
  prepareHotRestartShutdown,
21964
22158
  reconcileHotRestartAdoption,
21965
22159
  recoverNativeRunsAfterRestart,