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
@@ -13,6 +13,7 @@ import {
mergeCoalescedContextSnapshot,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveNextSessionState,
resolveRuntimeSessionParamsForWorkspace,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForWake,
@@ -59,6 +60,31 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
} as unknown as typeof agents.$inferSelect;
}
const hermesSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId.trim() : null;
return sessionId ? { sessionId } : null;
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = typeof params.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
return sessionId ? { sessionId } : null;
},
getDisplayId(params: Record<string, unknown> | null) {
return typeof params?.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
},
};
const truncatingHermesSessionCodec = {
...hermesSessionCodec,
getDisplayId(params: Record<string, unknown> | null) {
const sessionId = hermesSessionCodec.getDisplayId(params);
return sessionId ? sessionId.slice(0, 16) : null;
},
};
describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
@@ -496,6 +522,264 @@ describe("buildExplicitResumeSessionOverride", () => {
},
});
});
it("does not synthesize Hermes resume params from a truncated display id", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
taskSession: {
sessionParamsJson: {
sessionId: "20260601_141000_c861e4",
},
sessionDisplayId: "20260601_141000_",
lastRunId: "run-2",
},
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toBeNull();
});
it("uses validated Hermes run result params before truncated display ids", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "20260601_141558_c861e4",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("keeps Hermes run result params and display id together when falling back from a prior session", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "20260601_140000_old123",
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "20260601_141558_c861e4",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("ignores invalid Hermes run result params", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "from",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toBeNull();
});
it("keeps full Hermes task-session params even when the saved display id is truncated", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
taskSession: {
sessionParamsJson: {
sessionId: "20260601_141558_c861e4",
},
sessionDisplayId: "20260601_141558_",
lastRunId: "run-1",
},
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("falls back from a poisoned Hermes session-after value to a valid session-before value", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "20260601_141558_c861e4",
resumeRunSessionIdAfter: "from",
taskSession: null,
sessionCodec: hermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
});
describe("resolveNextSessionState", () => {
it("preserves previous valid Hermes session state when failed adapter output reports prose tokens", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "from",
},
sessionId: "from",
sessionDisplayId: "from",
errorMessage: "Session not found: 20260601_141558_",
},
outcome: "failed",
previousParams: {
sessionId: "20260601_141558_c861e4",
},
previousDisplayId: "20260601_141558_c861e4",
previousLegacySessionId: "20260601_141558_c861e4",
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("drops poisoned previous Hermes session state instead of passing it to the next run", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionId: "from",
sessionDisplayId: "from",
errorMessage: "Session not found: from",
},
outcome: "failed",
previousParams: {
sessionId: "from",
},
previousDisplayId: "from",
previousLegacySessionId: "from",
});
expect(result).toEqual({
params: null,
displayId: null,
legacySessionId: null,
});
});
it("derives Hermes display state from canonical params instead of adapter-truncated display ids", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
sessionDisplayId: "20260601_141558_",
},
outcome: "succeeded",
previousParams: null,
previousDisplayId: null,
previousLegacySessionId: null,
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("uses one canonical Hermes explicit session candidate instead of mixing valid and invalid fields", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "from",
},
sessionId: "20260601_141558_c861e4",
sessionDisplayId: "20260601_141558_",
},
outcome: "succeeded",
previousParams: {
sessionId: "20260601_140000_previous",
},
previousDisplayId: "20260601_140000_previous",
previousLegacySessionId: "20260601_140000_previous",
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("keeps non-Hermes arbitrary session ids unchanged", () => {
const result = resolveNextSessionState({
adapterType: "codex_local",
codec: codexSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionId: "from",
},
outcome: "failed",
previousParams: null,
previousDisplayId: null,
previousLegacySessionId: null,
});
expect(result.legacySessionId).toBe("from");
});
});
describe("formatRuntimeWorkspaceWarningLog", () => {