fix(heartbeat): guard Hermes resume session state (#7516)

## Thinking Path

> - Paperclip is a control plane for AI-agent companies
> - Heartbeats reuse adapter session state so agents can continue work
across wakeups
> - Hermes can only resume from full canonical session IDs, not
truncated display IDs
> - #6347 exposed a case where Paperclip could save invalid Hermes
output like `from`, or a shortened display ID, as resumable state
> - This pull request hardens the host-side Hermes resume path so
Paperclip only stores and reuses session IDs that can actually resume
> - The benefit is that Hermes wakeups no longer get stuck retrying bad
saved resume state

## What Changed

- Added Hermes-only validation for canonical session IDs in
`server/src/services/heartbeat.ts`.
- Stopped building Hermes resume params from truncated display IDs such
as `20260601_141558_`.
- For explicit resume-from-run wakeups, pulls the full Hermes session ID
from the run result payload after validation.
- Preserves the previous valid Hermes session state when a run fails,
times out, or is cancelled instead of replacing it with invalid adapter
output like `from`.
- Clears existing Hermes resume state that fails validation.
- Leaves non-Hermes adapter session behavior unchanged.
- Added regression coverage in
`server/src/__tests__/heartbeat-workspace-session.test.ts`.

Addresses #6347.

Supersedes #6351 and covers the full-session resume metadata handoff
from #7280.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-workspace-session.test.ts` — passed, 50 tests.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `git diff --check` — clean.

Full suite did not finish green locally; the failures were outside this
server-only heartbeat path:

- `pnpm test:run`:
  - `@paperclipai/adapter-opencode-local` remote SSH tests timed out.
- `ui/src/pages/Inbox.test.tsx` failed once in `Inbox toolbar > syncs
hover with j/k selection on inbox rows`; the direct file rerun passed.
- `ui/src/components/IssueDocumentAnnotations.test.tsx` failed once in
`auto-opens the panel and focuses the thread when deep-linked`; the
direct file rerun passed.

## Risks

- Low risk: no schema, public API, shared contract, or UI changes.
- If Hermes changes its canonical session ID format, the validation
regex will need to be updated.
- Adapter-side parsing still needs its own fix; this PR prevents
non-resumable adapter output from becoming durable Paperclip resume
state.
- This does not add an immediate same-run retry after `Session not
found`; recovery happens by clearing or preserving durable resume state
for later wakeups.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5.5 (`openai/gpt-5.5`) via opencode, with repository
read/search tools and local shell/test execution. opencode did not
expose context-window or reasoning-mode details.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used, including exact model ID and
capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally — targeted checks passed; full `pnpm
test:run` had unrelated local failures disclosed above
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A, no UI changes
- [x] I have updated relevant documentation to reflect my changes — N/A,
no user-facing docs or commands changed
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
m.seomoon
2026-06-05 14:45:25 +09:00
committed by GitHub
parent ec60da41ca
commit fb28cf38b4
2 changed files with 494 additions and 55 deletions
+210 -55
View File
@@ -1420,25 +1420,46 @@ type ResumeSessionRow = {
};
export function buildExplicitResumeSessionOverride(input: {
adapterType?: string | null;
resumeFromRunId: string;
resumeRunSessionIdBefore: string | null;
resumeRunSessionIdAfter: string | null;
resumeRunSessionParams?: Record<string, unknown> | null;
taskSession: ResumeSessionRow | null;
sessionCodec: AdapterSessionCodec;
}) {
const desiredDisplayId = truncateDisplayId(
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
);
const taskSessionParams = normalizeSessionParams(
const resumeRunSessionIdAfter = truncateDisplayId(input.resumeRunSessionIdAfter);
const resumeRunSessionIdBefore = truncateDisplayId(input.resumeRunSessionIdBefore);
const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType)
? isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdAfter)
? resumeRunSessionIdAfter
: isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdBefore)
? resumeRunSessionIdBefore
: null
: resumeRunSessionIdAfter ?? resumeRunSessionIdBefore;
const runSessionParams = requiresCanonicalSessionIds(input.adapterType)
? normalizeResumeParamsForAdapter(
input.adapterType,
input.sessionCodec.deserialize(input.resumeRunSessionParams ?? null),
)
: null;
const runSessionDisplayId = truncateDisplayId(readNonEmptyString(runSessionParams?.sessionId));
const taskSessionParams = normalizeResumeParamsForAdapter(
input.adapterType,
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
);
const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null;
const taskSessionDisplayId = truncateDisplayId(
input.taskSession?.sessionDisplayId ??
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
readNonEmptyString(taskSessionParams?.sessionId),
requiresCanonicalSessionIds(input.adapterType)
? readNonEmptyString(taskSessionParams?.sessionId) ??
(isCanonicalSessionIdForAdapter(input.adapterType, taskSessionRawDisplayId) ? taskSessionRawDisplayId : null)
: taskSessionRawDisplayId ??
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
readNonEmptyString(taskSessionParams?.sessionId),
);
const canReuseTaskSessionParams =
input.taskSession != null &&
(!requiresCanonicalSessionIds(input.adapterType) || taskSessionParams != null) &&
(
input.taskSession.lastRunId === input.resumeFromRunId ||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
@@ -1446,10 +1467,16 @@ export function buildExplicitResumeSessionOverride(input: {
const sessionParams =
canReuseTaskSessionParams
? taskSessionParams
: desiredDisplayId
? { sessionId: desiredDisplayId }
: null;
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
: runSessionParams
? runSessionParams
: desiredDisplayId
? { sessionId: desiredDisplayId }
: null;
const sessionDisplayId = canReuseTaskSessionParams
? taskSessionDisplayId
: runSessionParams
? runSessionDisplayId
: desiredDisplayId;
if (!sessionDisplayId && !sessionParams) return null;
return {
@@ -2442,14 +2469,45 @@ function normalizeSessionParams(params: Record<string, unknown> | null | undefin
return Object.keys(params).length > 0 ? params : null;
}
function resolveNextSessionState(input: {
type RunSessionOutcome = "succeeded" | "failed" | "cancelled" | "timed_out";
const HERMES_ADAPTER_TYPE = "hermes_local";
const HERMES_SESSION_ID_REGEX = /^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
function requiresCanonicalSessionIds(adapterType: string | null | undefined) {
return adapterType === HERMES_ADAPTER_TYPE;
}
function isCanonicalSessionIdForAdapter(
adapterType: string | null | undefined,
sessionId: string | null | undefined,
) {
if (!sessionId) return false;
if (!requiresCanonicalSessionIds(adapterType)) return true;
return HERMES_SESSION_ID_REGEX.test(sessionId);
}
function normalizeResumeParamsForAdapter(
adapterType: string | null | undefined,
params: Record<string, unknown> | null | undefined,
) {
const normalized = normalizeSessionParams(params);
if (!normalized) return null;
if (!requiresCanonicalSessionIds(adapterType)) return normalized;
const sessionId = readNonEmptyString(normalized.sessionId);
return isCanonicalSessionIdForAdapter(adapterType, sessionId) ? normalized : null;
}
export function resolveNextSessionState(input: {
adapterType?: string | null;
codec: AdapterSessionCodec;
adapterResult: AdapterExecutionResult;
outcome: RunSessionOutcome;
previousParams: Record<string, unknown> | null;
previousDisplayId: string | null;
previousLegacySessionId: string | null;
}) {
const { codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
const { adapterType, codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
if (adapterResult.clearSession) {
return {
@@ -2459,38 +2517,109 @@ function resolveNextSessionState(input: {
};
}
if (!requiresCanonicalSessionIds(adapterType)) {
const explicitParams = adapterResult.sessionParams;
const hasExplicitParams = adapterResult.sessionParams !== undefined;
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
const shouldUsePrevious = !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
const candidateParams =
hasExplicitParams
? explicitParams
: hasExplicitSessionId
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
: previousParams;
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
const displayId = truncateDisplayId(
explicitDisplayId ??
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
readNonEmptyString(deserialized?.sessionId) ??
(shouldUsePrevious ? previousDisplayId : null) ??
explicitSessionId ??
(shouldUsePrevious ? previousLegacySessionId : null),
);
const legacySessionId =
explicitSessionId ??
readNonEmptyString(deserialized?.sessionId) ??
displayId ??
(shouldUsePrevious ? previousLegacySessionId : null);
return {
params: serialized,
displayId,
legacySessionId,
};
}
const previousSerializedParams = normalizeResumeParamsForAdapter(
adapterType,
codec.serialize(normalizeResumeParamsForAdapter(adapterType, previousParams)),
);
const validPreviousDisplayId = isCanonicalSessionIdForAdapter(adapterType, previousDisplayId)
? previousDisplayId
: null;
const validPreviousLegacySessionId = isCanonicalSessionIdForAdapter(adapterType, previousLegacySessionId)
? previousLegacySessionId
: null;
const previousState = () => {
const displayId = truncateDisplayId(
readNonEmptyString(previousSerializedParams?.sessionId) ??
validPreviousDisplayId ??
validPreviousLegacySessionId,
);
return {
params: previousSerializedParams,
displayId,
legacySessionId: readNonEmptyString(previousSerializedParams?.sessionId) ?? displayId ?? validPreviousLegacySessionId,
};
};
if (input.outcome !== "succeeded") {
return previousState();
}
const explicitParams = adapterResult.sessionParams;
const hasExplicitParams = adapterResult.sessionParams !== undefined;
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
const validExplicitSessionId = isCanonicalSessionIdForAdapter(adapterType, explicitSessionId)
? explicitSessionId
: null;
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
const shouldUsePrevious = !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
const validExplicitDisplayId = isCanonicalSessionIdForAdapter(adapterType, explicitDisplayId)
? explicitDisplayId
: null;
const explicitSerializedParams = hasExplicitParams
? normalizeResumeParamsForAdapter(
adapterType,
codec.serialize(normalizeSessionParams(explicitParams) ?? null),
)
: null;
const explicitCanonicalSessionId =
readNonEmptyString(explicitSerializedParams?.sessionId) ??
validExplicitSessionId ??
validExplicitDisplayId;
const candidateParams =
hasExplicitParams
? explicitParams
: hasExplicitSessionId
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
: previousParams;
if (!explicitCanonicalSessionId) {
return previousState();
}
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
const displayId = truncateDisplayId(
explicitDisplayId ??
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
readNonEmptyString(deserialized?.sessionId) ??
(shouldUsePrevious ? previousDisplayId : null) ??
explicitSessionId ??
(shouldUsePrevious ? previousLegacySessionId : null),
const serialized = normalizeResumeParamsForAdapter(
adapterType,
codec.serialize({ sessionId: explicitCanonicalSessionId }),
);
const legacySessionId =
explicitSessionId ??
readNonEmptyString(deserialized?.sessionId) ??
displayId ??
(shouldUsePrevious ? previousLegacySessionId : null);
const displayId = truncateDisplayId(
readNonEmptyString(serialized?.sessionId) ??
(codec.getDisplayId ? codec.getDisplayId(serialized) : null) ??
explicitCanonicalSessionId,
);
const legacySessionId = readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId;
return {
params: serialized,
@@ -3626,6 +3755,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.select({
id: heartbeatRuns.id,
contextSnapshot: heartbeatRuns.contextSnapshot,
resultJson: heartbeatRuns.resultJson,
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
})
@@ -3646,10 +3776,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
: null;
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const resumeRunResult = parseObject(resumeRun.resultJson);
const resumeRunSessionId = requiresCanonicalSessionIds(agent.adapterType)
? readNonEmptyString(resumeRunResult.sessionId) ?? readNonEmptyString(resumeRunResult.session_id)
: null;
const sessionOverride = buildExplicitResumeSessionOverride({
adapterType: agent.adapterType,
resumeFromRunId,
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
resumeRunSessionParams: resumeRunSessionId ? { sessionId: resumeRunSessionId } : null,
taskSession: resumeTaskSession,
sessionCodec,
});
@@ -7168,7 +7304,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const resetTaskSession = shouldResetTaskSessionForWake(context);
const sessionResetReason = describeSessionResetReason(context);
const taskSessionForRun = resetTaskSession ? null : taskSession;
const explicitResumeSessionParams = normalizeSessionParams(
const explicitResumeSessionParams = normalizeResumeParamsForAdapter(
agent.adapterType,
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
);
const explicitResumeSessionDisplayId = truncateDisplayId(
@@ -7178,8 +7315,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
(isCanonicalSessionIdForAdapter(agent.adapterType, explicitResumeSessionDisplayId)
? { sessionId: explicitResumeSessionDisplayId }
: null) ??
normalizeResumeParamsForAdapter(
agent.adapterType,
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
);
const config = parseObject(agent.adapterConfig);
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
@@ -7740,14 +7882,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = executionWorkspace.projectId;
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
let previousSessionDisplayId = truncateDisplayId(
const runtimeSessionFallback = taskKey || resetTaskSession
? null
: isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
? runtime.sessionId
: null;
const runtimeSessionDisplayId = truncateDisplayId(
explicitResumeSessionDisplayId ??
taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback,
);
let previousSessionDisplayId = requiresCanonicalSessionIds(agent.adapterType)
? truncateDisplayId(
readNonEmptyString(previousSessionParams?.sessionId) ??
(isCanonicalSessionIdForAdapter(agent.adapterType, runtimeSessionDisplayId) ? runtimeSessionDisplayId : null) ??
runtimeSessionFallback,
)
: runtimeSessionDisplayId;
let runtimeSessionIdForAdapter =
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
let runtimeSessionParamsForAdapter = runtimeSessionParams;
@@ -8136,9 +8289,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
}
let outcome: RunSessionOutcome;
const latestRun = await getRun(run.id);
if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
outcome = latestRun.status;
} else if (adapterResult.timedOut) {
outcome = "timed_out";
} else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) {
outcome = "succeeded";
} else {
outcome = "failed";
}
const nextSessionState = resolveNextSessionState({
adapterType: agent.adapterType,
codec: sessionCodec,
adapterResult,
outcome,
previousParams: previousSessionParams,
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
@@ -8151,18 +8318,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
rawUsage,
});
const normalizedUsage = sessionUsageResolution.normalizedUsage;
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
const latestRun = await getRun(run.id);
if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
outcome = latestRun.status;
} else if (adapterResult.timedOut) {
outcome = "timed_out";
} else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) {
outcome = "succeeded";
} else {
outcome = "failed";
}
const runErrorMessage =
outcome === "cancelled"
? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")