From fb28cf38b4aae6de91f846940c45e917925f74b0 Mon Sep 17 00:00:00 2001 From: "m.seomoon" Date: Fri, 5 Jun 2026 14:45:25 +0900 Subject: [PATCH] fix(heartbeat): guard Hermes resume session state (#7516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../heartbeat-workspace-session.test.ts | 284 ++++++++++++++++++ server/src/services/heartbeat.ts | 265 ++++++++++++---- 2 files changed, 494 insertions(+), 55 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 47282db0..a9affbf8 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -13,6 +13,7 @@ import { mergeCoalescedContextSnapshot, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, + resolveNextSessionState, resolveRuntimeSessionParamsForWorkspace, stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForWake, @@ -59,6 +60,31 @@ function buildAgent(adapterType: string, runtimeConfig: Record } 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; + const sessionId = typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId.trim() : null; + return sessionId ? { sessionId } : null; + }, + serialize(params: Record | 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 | null) { + return typeof params?.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null; + }, +}; + +const truncatingHermesSessionCodec = { + ...hermesSessionCodec, + getDisplayId(params: Record | 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", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 06b202ec..5adaeb2f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1420,25 +1420,46 @@ type ResumeSessionRow = { }; export function buildExplicitResumeSessionOverride(input: { + adapterType?: string | null; resumeFromRunId: string; resumeRunSessionIdBefore: string | null; resumeRunSessionIdAfter: string | null; + resumeRunSessionParams?: Record | 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 | 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 | 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 | 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")