@tickernelz/paperclip-pro-server 2026.1001.0 → 2026.1001.2

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 (28) 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/heartbeat.d.ts +18 -2
  5. package/dist/services/heartbeat.d.ts.map +1 -1
  6. package/dist/services/heartbeat.js +194 -30
  7. package/dist/services/heartbeat.js.map +1 -1
  8. package/dist/services/hot-restart.d.ts +4 -3
  9. package/dist/services/hot-restart.d.ts.map +1 -1
  10. package/dist/services/hot-restart.js +1 -1
  11. package/dist/services/hot-restart.js.map +1 -1
  12. package/dist/services/recovery/service.d.ts +3 -0
  13. package/dist/services/recovery/service.d.ts.map +1 -1
  14. package/dist/services/recovery/service.js +154 -102
  15. package/dist/services/recovery/service.js.map +1 -1
  16. package/package.json +21 -21
  17. package/ui-dist/assets/{Agents.production-fNeT2seP.js → Agents.production-tjOEQe3l.js} +2 -2
  18. package/ui-dist/assets/{CompanyActivity.production-CEF0hx9W.js → CompanyActivity.production-DeT7xKMQ.js} +1 -1
  19. package/ui-dist/assets/{CompanyExport-DT63ZLv6.js → CompanyExport-BLyfRuJ5.js} +1 -1
  20. package/ui-dist/assets/{CompanySkills.production-74w4FgIl.js → CompanySkills.production-BQbzHmtn.js} +1 -1
  21. package/ui-dist/assets/{Costs.production-B0j-0g6E.js → Costs.production-5RBWzUGB.js} +1 -1
  22. package/ui-dist/assets/{OrgChart.production-BdkDUcmQ.js → OrgChart.production-D_5tQ3tx.js} +1 -1
  23. package/ui-dist/assets/{RoutineDetail.production-grakkTMs.js → RoutineDetail.production-Dd163Q10.js} +1 -1
  24. package/ui-dist/assets/{Routines.production-qCZmmUZQ.js → Routines.production-g9pJjOvz.js} +1 -1
  25. package/ui-dist/assets/{definition-6ehunDEH.js → definition-BtNjkL-Z.js} +1 -1
  26. package/ui-dist/assets/{index-DrB9-JGQ.js → index-B_VCIODd.js} +4 -4
  27. package/ui-dist/index.html +1 -1
  28. package/ui-dist/sw.js +1 -1
@@ -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";
@@ -335,6 +336,11 @@ const PROCESS_LOSS_ERROR_CODES = {
335
336
  function isTransientWorkspaceGitScanCode(code) {
336
337
  return code === WORKSPACE_GIT_SCAN_ERROR_CODES.timeout || code === WORKSPACE_GIT_SCAN_ERROR_CODES.saturated;
337
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
+ };
338
344
  const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
339
345
  export { INTERACTION_CONTINUATION_INFRA_RETRY_REASON, INTERACTION_CONTINUATION_INFRA_WAKE_REASON, };
340
346
  const INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS = 2;
@@ -807,6 +813,15 @@ const nativeSessionResumeDispatchTimers = new Map();
807
813
  // resolveHeartbeatSchedulingSuppression() check and every heartbeatService()
808
814
  // instance see the same drain.
809
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
+ }
810
825
  function readTaskDrain(now) {
811
826
  if (taskDrainState &&
812
827
  taskDrainState.expiresAt !== null &&
@@ -3926,6 +3941,7 @@ const SESSION_CONFIG_FINGERPRINT_KEY = "__paperclipConfigFingerprint";
3926
3941
  const SESSION_CONFIG_FINGERPRINT_VERSION_KEY = "__paperclipConfigFingerprintVersion";
3927
3942
  const SESSION_CONFIG_CATEGORIES_KEY = "__paperclipConfigCategories";
3928
3943
  const SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY = "__paperclipConfigCategoryFingerprints";
3944
+ const SESSION_CONFIG_RESUME_FINGERPRINT_KEY = "__paperclipConfigResumeFingerprint";
3929
3945
  const PAPERCLIP_SESSION_METADATA_KEYS = new Set([
3930
3946
  SESSION_AI_CREDENTIAL_IDENTITY_KEY,
3931
3947
  SESSION_CONFIGURED_MODEL_KEY,
@@ -3933,6 +3949,7 @@ const PAPERCLIP_SESSION_METADATA_KEYS = new Set([
3933
3949
  SESSION_CONFIG_FINGERPRINT_VERSION_KEY,
3934
3950
  SESSION_CONFIG_CATEGORIES_KEY,
3935
3951
  SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY,
3952
+ SESSION_CONFIG_RESUME_FINGERPRINT_KEY,
3936
3953
  ]);
3937
3954
  const WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY = "configFingerprint";
3938
3955
  const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES = [
@@ -3957,6 +3974,19 @@ const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES = [
3957
3974
  "environment",
3958
3975
  "realization",
3959
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 };
3960
3990
  /**
3961
3991
  * Projectless native runs bind their immutable envelope to the run id even
3962
3992
  * though no project-scoped execution-workspace row exists. Treat that value
@@ -4127,6 +4157,7 @@ function readConfigFingerprintFromSessionParams(sessionParams) {
4127
4157
  version,
4128
4158
  categories: readConfigCategoriesFromSessionParams(sessionParams),
4129
4159
  categoryFingerprints: parseStoredConfigCategoryFingerprints(sessionParams[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY]),
4160
+ resumeFingerprint: readNonEmptyString(sessionParams[SESSION_CONFIG_RESUME_FINGERPRINT_KEY]),
4130
4161
  };
4131
4162
  }
4132
4163
  function describeEffectiveRunConfigCategories(categories) {
@@ -4375,9 +4406,10 @@ function buildSessionConfigCategoryValues(input) {
4375
4406
  export async function buildEffectiveRunSessionConfigMetadata(input) {
4376
4407
  const secretManifest = input.secretManifest ?? [];
4377
4408
  const instructions = await resolveInstructionsConfigFingerprintMetadata(input.effectiveAdapterConfig);
4409
+ const fingerprintAdapterConfig = managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome);
4378
4410
  const categoryValues = buildSessionConfigCategoryValues({
4379
4411
  adapterType: input.adapterType,
4380
- effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome),
4412
+ effectiveAdapterConfig: fingerprintAdapterConfig,
4381
4413
  agentRuntimeConfig: input.agentRuntimeConfig,
4382
4414
  instructions,
4383
4415
  issueOverrides: input.issueOverrides,
@@ -4400,11 +4432,26 @@ export async function buildEffectiveRunSessionConfigMetadata(input) {
4400
4432
  subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES,
4401
4433
  secretManifest,
4402
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
+ });
4403
4449
  return {
4404
4450
  version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
4405
4451
  fingerprint: fingerprints.sessionFingerprint.fingerprint,
4406
4452
  categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES],
4407
4453
  categoryFingerprints,
4454
+ resumeFingerprint,
4408
4455
  fingerprints,
4409
4456
  };
4410
4457
  }
@@ -4618,6 +4665,8 @@ function attachPaperclipSessionMetadataToSessionParams(sessionParams, configured
4618
4665
  next[SESSION_CONFIG_CATEGORIES_KEY] = configMetadata.categories;
4619
4666
  next[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY] =
4620
4667
  configMetadata.categoryFingerprints;
4668
+ next[SESSION_CONFIG_RESUME_FINGERPRINT_KEY] =
4669
+ configMetadata.resumeFingerprint;
4621
4670
  }
4622
4671
  return next;
4623
4672
  }
@@ -4647,6 +4696,17 @@ export function stripPaperclipSessionMetadataFromSessionParams(sessionParams) {
4647
4696
  }
4648
4697
  return next;
4649
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
+ }
4650
4710
  export function resolveTaskSessionConfigFreshness(input) {
4651
4711
  if (!input.hasTaskSession) {
4652
4712
  return {
@@ -4684,7 +4744,14 @@ export function resolveTaskSessionConfigFreshness(input) {
4684
4744
  previous: storedConfig.categoryFingerprints,
4685
4745
  next: input.configMetadata.categoryFingerprints,
4686
4746
  });
4687
- 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
+ }
4688
4755
  }
4689
4756
  }
4690
4757
  if (input.wakeResetReason)
@@ -6266,18 +6333,20 @@ export function resolveHeartbeatSchedulingSuppression(env = process.env, overrid
6266
6333
  isTruthyRuntimeEnvValue(env.PAPERCLIP_RESTORE_IN_PROGRESS)) {
6267
6334
  return { suppressed: true, reason: "database_restore_in_progress" };
6268
6335
  }
6336
+ if (serverShutdownSignal !== null) {
6337
+ return { suppressed: true, reason: "server_shutdown" };
6338
+ }
6269
6339
  if (readTaskDrain(new Date()) !== null) {
6270
6340
  return { suppressed: true, reason: "task_drain" };
6271
6341
  }
6272
6342
  return { suppressed: false, reason: null };
6273
6343
  }
6274
6344
  export function heartbeatService(db, options = {}) {
6275
- let shutdownInProgress = false;
6345
+ const runtimeEnv = options.runtimeEnv ?? process.env;
6276
6346
  const instanceSettings = instanceSettingsService(db);
6277
6347
  const getCurrentUserRedactionOptions = async () => ({
6278
6348
  enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
6279
6349
  });
6280
- const runtimeEnv = options.runtimeEnv ?? process.env;
6281
6350
  const inWorktreeRuntime = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE);
6282
6351
  // Preview worktree instances suppress the run engine by default. Users can lift
6283
6352
  // that per-worktree via the `enableWorktreeRunExecution` experimental setting
@@ -10078,7 +10147,7 @@ export function heartbeatService(db, options = {}) {
10078
10147
  return (readNonEmptyString(parseObject(input.adapterConfig).engine) !== "cli");
10079
10148
  }
10080
10149
  async function prepareHotRestartShutdown(signal, now = new Date()) {
10081
- shutdownInProgress = true;
10150
+ beginHeartbeatServerShutdown(signal);
10082
10151
  const idleSessions = await closeIdleWarmNativeSessionsForRestart();
10083
10152
  if (idleSessions.failed > 0) {
10084
10153
  logger.warn({ idleSessions }, "idle native sessions could not checkpoint before controller shutdown");
@@ -10129,6 +10198,25 @@ export function heartbeatService(db, options = {}) {
10129
10198
  ...intent,
10130
10199
  previousServerVersion: intent.previousServerVersion ?? serverVersion,
10131
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
+ }
10132
10220
  const serverStdioRuns = activeRuns.filter(isServerStdioBoundHotRestartRun);
10133
10221
  if (serverStdioRuns.length > 0) {
10134
10222
  const activeServerStdioRunIds = serverStdioRuns.map(({ run }) => run.id);
@@ -10305,18 +10393,15 @@ export function heartbeatService(db, options = {}) {
10305
10393
  classify(candidate, "finalized_while_down", `run_status_${run.status}`, patch);
10306
10394
  continue;
10307
10395
  }
10308
- const hasSelectiveAcpDrain = intent.drainReason === "active_acp_run" &&
10396
+ const hasSelectiveDrain = (intent.drainReason === "active_acp_run" ||
10397
+ intent.drainReason === "service_stop_kills_children") &&
10309
10398
  (intent.drainRunIds?.length ?? 0) > 0;
10310
- if (hasSelectiveAcpDrain &&
10399
+ if (hasSelectiveDrain &&
10311
10400
  intent.drainRunIds?.includes(candidate.runId)) {
10312
- // A selective ACP drain is expected to persist a terminal row before
10313
- // the new server starts. If the process was terminated but that write
10314
- // failed, surface the run as lost instead of hiding it as an expected
10315
- // drain skip.
10316
10401
  classify(candidate, "lost", "selective_drain_not_finalized", patch);
10317
10402
  continue;
10318
10403
  }
10319
- if (intent.drainRequired && !hasSelectiveAcpDrain) {
10404
+ if (intent.drainRequired && !hasSelectiveDrain) {
10320
10405
  classify(candidate, "skipped", "drain_required", patch);
10321
10406
  continue;
10322
10407
  }
@@ -10544,6 +10629,7 @@ export function heartbeatService(db, options = {}) {
10544
10629
  };
10545
10630
  }
10546
10631
  async function drainRunningRunsForShutdown(signal, now = new Date(), runIds = null) {
10632
+ beginHeartbeatServerShutdown(signal);
10547
10633
  const selectedRunIds = runIds ? [...new Set(runIds)] : null;
10548
10634
  if (selectedRunIds?.length === 0) {
10549
10635
  return {
@@ -10566,6 +10652,27 @@ export function heartbeatService(db, options = {}) {
10566
10652
  const interruptedRunIds = [];
10567
10653
  const retryRunIds = [];
10568
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
+ }
10569
10676
  for (const { run, agent } of activeRuns) {
10570
10677
  // Shutdown owns only this boot's legacy executions. Expired foreign
10571
10678
  // owners belong to the reaper, not another container's drain.
@@ -10639,7 +10746,12 @@ export function heartbeatService(db, options = {}) {
10639
10746
  runtimeMode: run.runtimeMode,
10640
10747
  });
10641
10748
  }
10642
- 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) {
10643
10755
  await terminateHeartbeatRunProcess({
10644
10756
  pid: running.child.pid,
10645
10757
  processGroupId: running.processGroupId,
@@ -10665,8 +10777,20 @@ export function heartbeatService(db, options = {}) {
10665
10777
  errorMessage: message,
10666
10778
  }),
10667
10779
  });
10668
- 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
+ }
10669
10792
  continue;
10793
+ }
10670
10794
  let interrupted = interruptedStatus.run;
10671
10795
  await setWakeupStatus(run.wakeupRequestId, "cancelled", {
10672
10796
  finishedAt: now,
@@ -15625,6 +15749,7 @@ export function heartbeatService(db, options = {}) {
15625
15749
  taskSessionParams: taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
15626
15750
  configMetadata: sessionConfigMetadata,
15627
15751
  wakeResetReason: wakeSessionResetReason,
15752
+ processLossContinuation: isProcessLossContinuationRun(run, context),
15628
15753
  preserveLegacySessionWithoutConfigMetadata: acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision,
15629
15754
  });
15630
15755
  const resetTaskSession = shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
@@ -18302,7 +18427,12 @@ export function heartbeatService(db, options = {}) {
18302
18427
  failedProcessRunCancellations.get(run.id);
18303
18428
  await processCancellation?.settled;
18304
18429
  let outcome;
18430
+ let processLossErrorCode = null;
18305
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);
18306
18436
  if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
18307
18437
  outcome = latestRun.status;
18308
18438
  }
@@ -18318,6 +18448,11 @@ export function heartbeatService(db, options = {}) {
18318
18448
  ? "cancelled"
18319
18449
  : "failed";
18320
18450
  }
18451
+ else if (shutdownTerminatedRunIds.has(run.id) &&
18452
+ !paperclipCancellationRecorded) {
18453
+ outcome = "interrupted";
18454
+ processLossErrorCode = "server_shutdown_interrupted";
18455
+ }
18321
18456
  else if (adapterResult.timedOut) {
18322
18457
  outcome = "timed_out";
18323
18458
  }
@@ -18329,6 +18464,20 @@ export function heartbeatService(db, options = {}) {
18329
18464
  }
18330
18465
  else {
18331
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
+ }
18332
18481
  }
18333
18482
  const nextSessionState = resolveNextSessionState({
18334
18483
  adapterType: agent.adapterType,
@@ -18352,18 +18501,21 @@ export function heartbeatService(db, options = {}) {
18352
18501
  ? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
18353
18502
  : outcome === "succeeded"
18354
18503
  ? null
18355
- : redactCurrentUserText(adapterResult.errorMessage ??
18356
- (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);
18357
18508
  const recordedResponsibleUserDenialCode = normalizeResponsibleUserDenialCode(latestRun?.errorCode);
18358
- const runErrorCode = outcome === "timed_out"
18359
- ? "timeout"
18360
- : outcome === "cancelled"
18361
- ? (latestRun?.errorCode ?? "cancelled")
18362
- : outcome === "failed"
18363
- ? (adapterResult.errorCode ??
18364
- recordedResponsibleUserDenialCode ??
18365
- "adapter_failed")
18366
- : 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);
18367
18519
  let logSummary = null;
18368
18520
  if (handle) {
18369
18521
  logSummary = await runLogStore.finalize(handle);
@@ -18388,7 +18540,9 @@ export function heartbeatService(db, options = {}) {
18388
18540
  ? "cancelled"
18389
18541
  : outcome === "timed_out"
18390
18542
  ? "timed_out"
18391
- : "failed";
18543
+ : outcome === "interrupted"
18544
+ ? "interrupted"
18545
+ : "failed";
18392
18546
  const cacheAdjustedCostUsd = resolveCacheAdjustedCostUsd(adapterResult);
18393
18547
  const usageJson = normalizedUsage ||
18394
18548
  adapterResult.costUsd != null ||
@@ -18512,7 +18666,11 @@ export function heartbeatService(db, options = {}) {
18512
18666
  persistedRun =
18513
18667
  (await classifyAndPersistRunLiveness(persistedRun, persistedResultJson)) ?? persistedRun;
18514
18668
  }
18515
- await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, {
18669
+ await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded"
18670
+ ? "completed"
18671
+ : outcome === "interrupted"
18672
+ ? "cancelled"
18673
+ : status, {
18516
18674
  finishedAt: new Date(),
18517
18675
  error: runErrorMessage,
18518
18676
  });
@@ -18654,7 +18812,10 @@ export function heartbeatService(db, options = {}) {
18654
18812
  catch (err) {
18655
18813
  await onLog("stderr", `[paperclip] Failed to resolve run presentation: ${err instanceof Error ? err.message : String(err)}\n`);
18656
18814
  }
18657
- if (outcome === "failed" && isMaxTurnExhaustionRun(livenessRun)) {
18815
+ if (processLossErrorCode) {
18816
+ await enqueueProcessLossRetry(livenessRun, agent, new Date());
18817
+ }
18818
+ else if (outcome === "failed" && isMaxTurnExhaustionRun(livenessRun)) {
18658
18819
  const policy = parseMaxTurnContinuationPolicy(agent);
18659
18820
  if (policy.enabled && policy.maxAttempts > 0) {
18660
18821
  await scheduleBoundedRetryForRun(livenessRun, agent, {
@@ -19370,6 +19531,7 @@ export function heartbeatService(db, options = {}) {
19370
19531
  finally {
19371
19532
  controllerLease.stop();
19372
19533
  activeRunExecutions.delete(run.id);
19534
+ shutdownTerminatedRunIds.delete(run.id);
19373
19535
  clearLocalCliRunStarting(run.id);
19374
19536
  // A failed owned Stop remains visible until this exact executor settles,
19375
19537
  // including a graceful exit result arriving after the cancellation error.
@@ -19393,7 +19555,7 @@ export function heartbeatService(db, options = {}) {
19393
19555
  }
19394
19556
  if (!nativeSessionResumeScheduled &&
19395
19557
  !nativeWorkspaceFinalizeScheduled &&
19396
- !shutdownInProgress) {
19558
+ serverShutdownSignal === null) {
19397
19559
  if (latestRun)
19398
19560
  await resumeRemoteStopComments(latestRun).catch(err => {
19399
19561
  logger.warn({ err, runId: run.id }, "failed to resume user messages after remote Stop");
@@ -19651,7 +19813,8 @@ export function heartbeatService(db, options = {}) {
19651
19813
  // continuation wake landed mid-drain left its issue in `todo` with no
19652
19814
  // run and no path until a person noticed.
19653
19815
  if (schedulingSuppression.suppressed &&
19654
- schedulingSuppression.reason !== "task_drain") {
19816
+ schedulingSuppression.reason !== "task_drain" &&
19817
+ schedulingSuppression.reason !== "server_shutdown") {
19655
19818
  await writeSkippedHeartbeatRequest("heartbeat.scheduling_suppressed", {
19656
19819
  reason: schedulingSuppression.reason,
19657
19820
  });
@@ -21990,6 +22153,7 @@ export function heartbeatService(db, options = {}) {
21990
22153
  dispatchPendingNativeStatusWakeups,
21991
22154
  triggerIssueMonitor,
21992
22155
  reportRunActivity: clearDetachedRunWarning,
22156
+ beginServerShutdown: beginHeartbeatServerShutdown,
21993
22157
  prepareHotRestartShutdown,
21994
22158
  reconcileHotRestartAdoption,
21995
22159
  recoverNativeRunsAfterRestart,