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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user