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,
|
mergeCoalescedContextSnapshot,
|
||||||
prioritizeProjectWorkspaceCandidatesForRun,
|
prioritizeProjectWorkspaceCandidatesForRun,
|
||||||
parseSessionCompactionPolicy,
|
parseSessionCompactionPolicy,
|
||||||
|
resolveNextSessionState,
|
||||||
resolveRuntimeSessionParamsForWorkspace,
|
resolveRuntimeSessionParamsForWorkspace,
|
||||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||||
shouldResetTaskSessionForWake,
|
shouldResetTaskSessionForWake,
|
||||||
@@ -59,6 +60,31 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
|
|||||||
} as unknown as typeof agents.$inferSelect;
|
} 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", () => {
|
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||||
const agentId = "agent-123";
|
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", () => {
|
describe("formatRuntimeWorkspaceWarningLog", () => {
|
||||||
|
|||||||
@@ -1420,25 +1420,46 @@ type ResumeSessionRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildExplicitResumeSessionOverride(input: {
|
export function buildExplicitResumeSessionOverride(input: {
|
||||||
|
adapterType?: string | null;
|
||||||
resumeFromRunId: string;
|
resumeFromRunId: string;
|
||||||
resumeRunSessionIdBefore: string | null;
|
resumeRunSessionIdBefore: string | null;
|
||||||
resumeRunSessionIdAfter: string | null;
|
resumeRunSessionIdAfter: string | null;
|
||||||
|
resumeRunSessionParams?: Record<string, unknown> | null;
|
||||||
taskSession: ResumeSessionRow | null;
|
taskSession: ResumeSessionRow | null;
|
||||||
sessionCodec: AdapterSessionCodec;
|
sessionCodec: AdapterSessionCodec;
|
||||||
}) {
|
}) {
|
||||||
const desiredDisplayId = truncateDisplayId(
|
const resumeRunSessionIdAfter = truncateDisplayId(input.resumeRunSessionIdAfter);
|
||||||
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
|
const resumeRunSessionIdBefore = truncateDisplayId(input.resumeRunSessionIdBefore);
|
||||||
);
|
const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType)
|
||||||
const taskSessionParams = normalizeSessionParams(
|
? 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),
|
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
|
||||||
);
|
);
|
||||||
|
const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null;
|
||||||
const taskSessionDisplayId = truncateDisplayId(
|
const taskSessionDisplayId = truncateDisplayId(
|
||||||
input.taskSession?.sessionDisplayId ??
|
requiresCanonicalSessionIds(input.adapterType)
|
||||||
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
|
? readNonEmptyString(taskSessionParams?.sessionId) ??
|
||||||
readNonEmptyString(taskSessionParams?.sessionId),
|
(isCanonicalSessionIdForAdapter(input.adapterType, taskSessionRawDisplayId) ? taskSessionRawDisplayId : null)
|
||||||
|
: taskSessionRawDisplayId ??
|
||||||
|
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
|
||||||
|
readNonEmptyString(taskSessionParams?.sessionId),
|
||||||
);
|
);
|
||||||
const canReuseTaskSessionParams =
|
const canReuseTaskSessionParams =
|
||||||
input.taskSession != null &&
|
input.taskSession != null &&
|
||||||
|
(!requiresCanonicalSessionIds(input.adapterType) || taskSessionParams != null) &&
|
||||||
(
|
(
|
||||||
input.taskSession.lastRunId === input.resumeFromRunId ||
|
input.taskSession.lastRunId === input.resumeFromRunId ||
|
||||||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
|
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
|
||||||
@@ -1446,10 +1467,16 @@ export function buildExplicitResumeSessionOverride(input: {
|
|||||||
const sessionParams =
|
const sessionParams =
|
||||||
canReuseTaskSessionParams
|
canReuseTaskSessionParams
|
||||||
? taskSessionParams
|
? taskSessionParams
|
||||||
: desiredDisplayId
|
: runSessionParams
|
||||||
? { sessionId: desiredDisplayId }
|
? runSessionParams
|
||||||
: null;
|
: desiredDisplayId
|
||||||
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
|
? { sessionId: desiredDisplayId }
|
||||||
|
: null;
|
||||||
|
const sessionDisplayId = canReuseTaskSessionParams
|
||||||
|
? taskSessionDisplayId
|
||||||
|
: runSessionParams
|
||||||
|
? runSessionDisplayId
|
||||||
|
: desiredDisplayId;
|
||||||
|
|
||||||
if (!sessionDisplayId && !sessionParams) return null;
|
if (!sessionDisplayId && !sessionParams) return null;
|
||||||
return {
|
return {
|
||||||
@@ -2442,14 +2469,45 @@ function normalizeSessionParams(params: Record<string, unknown> | null | undefin
|
|||||||
return Object.keys(params).length > 0 ? params : null;
|
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;
|
codec: AdapterSessionCodec;
|
||||||
adapterResult: AdapterExecutionResult;
|
adapterResult: AdapterExecutionResult;
|
||||||
|
outcome: RunSessionOutcome;
|
||||||
previousParams: Record<string, unknown> | null;
|
previousParams: Record<string, unknown> | null;
|
||||||
previousDisplayId: string | null;
|
previousDisplayId: string | null;
|
||||||
previousLegacySessionId: string | null;
|
previousLegacySessionId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
|
const { adapterType, codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
|
||||||
|
|
||||||
if (adapterResult.clearSession) {
|
if (adapterResult.clearSession) {
|
||||||
return {
|
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 explicitParams = adapterResult.sessionParams;
|
||||||
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
||||||
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
|
|
||||||
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
||||||
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
|
const validExplicitSessionId = isCanonicalSessionIdForAdapter(adapterType, explicitSessionId)
|
||||||
|
? explicitSessionId
|
||||||
|
: null;
|
||||||
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
|
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 =
|
if (!explicitCanonicalSessionId) {
|
||||||
hasExplicitParams
|
return previousState();
|
||||||
? explicitParams
|
}
|
||||||
: hasExplicitSessionId
|
|
||||||
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
|
|
||||||
: previousParams;
|
|
||||||
|
|
||||||
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
|
const serialized = normalizeResumeParamsForAdapter(
|
||||||
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
|
adapterType,
|
||||||
|
codec.serialize({ sessionId: explicitCanonicalSessionId }),
|
||||||
const displayId = truncateDisplayId(
|
|
||||||
explicitDisplayId ??
|
|
||||||
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
|
|
||||||
readNonEmptyString(deserialized?.sessionId) ??
|
|
||||||
(shouldUsePrevious ? previousDisplayId : null) ??
|
|
||||||
explicitSessionId ??
|
|
||||||
(shouldUsePrevious ? previousLegacySessionId : null),
|
|
||||||
);
|
);
|
||||||
|
const displayId = truncateDisplayId(
|
||||||
const legacySessionId =
|
readNonEmptyString(serialized?.sessionId) ??
|
||||||
explicitSessionId ??
|
(codec.getDisplayId ? codec.getDisplayId(serialized) : null) ??
|
||||||
readNonEmptyString(deserialized?.sessionId) ??
|
explicitCanonicalSessionId,
|
||||||
displayId ??
|
);
|
||||||
(shouldUsePrevious ? previousLegacySessionId : null);
|
const legacySessionId = readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
params: serialized,
|
params: serialized,
|
||||||
@@ -3626,6 +3755,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
.select({
|
.select({
|
||||||
id: heartbeatRuns.id,
|
id: heartbeatRuns.id,
|
||||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||||
|
resultJson: heartbeatRuns.resultJson,
|
||||||
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||||
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||||
})
|
})
|
||||||
@@ -3646,10 +3776,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
|
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
|
||||||
: null;
|
: null;
|
||||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
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({
|
const sessionOverride = buildExplicitResumeSessionOverride({
|
||||||
|
adapterType: agent.adapterType,
|
||||||
resumeFromRunId,
|
resumeFromRunId,
|
||||||
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
|
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
|
||||||
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
|
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
|
||||||
|
resumeRunSessionParams: resumeRunSessionId ? { sessionId: resumeRunSessionId } : null,
|
||||||
taskSession: resumeTaskSession,
|
taskSession: resumeTaskSession,
|
||||||
sessionCodec,
|
sessionCodec,
|
||||||
});
|
});
|
||||||
@@ -7168,7 +7304,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
||||||
const sessionResetReason = describeSessionResetReason(context);
|
const sessionResetReason = describeSessionResetReason(context);
|
||||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||||
const explicitResumeSessionParams = normalizeSessionParams(
|
const explicitResumeSessionParams = normalizeResumeParamsForAdapter(
|
||||||
|
agent.adapterType,
|
||||||
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
|
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
|
||||||
);
|
);
|
||||||
const explicitResumeSessionDisplayId = truncateDisplayId(
|
const explicitResumeSessionDisplayId = truncateDisplayId(
|
||||||
@@ -7178,8 +7315,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
);
|
);
|
||||||
const previousSessionParams =
|
const previousSessionParams =
|
||||||
explicitResumeSessionParams ??
|
explicitResumeSessionParams ??
|
||||||
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
|
(isCanonicalSessionIdForAdapter(agent.adapterType, explicitResumeSessionDisplayId)
|
||||||
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
|
? { sessionId: explicitResumeSessionDisplayId }
|
||||||
|
: null) ??
|
||||||
|
normalizeResumeParamsForAdapter(
|
||||||
|
agent.adapterType,
|
||||||
|
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||||
|
);
|
||||||
const config = parseObject(agent.adapterConfig);
|
const config = parseObject(agent.adapterConfig);
|
||||||
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||||
projectPolicy: projectExecutionWorkspacePolicy,
|
projectPolicy: projectExecutionWorkspacePolicy,
|
||||||
@@ -7740,14 +7882,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||||
context.projectId = executionWorkspace.projectId;
|
context.projectId = executionWorkspace.projectId;
|
||||||
}
|
}
|
||||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
const runtimeSessionFallback = taskKey || resetTaskSession
|
||||||
let previousSessionDisplayId = truncateDisplayId(
|
? null
|
||||||
|
: isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
|
||||||
|
? runtime.sessionId
|
||||||
|
: null;
|
||||||
|
const runtimeSessionDisplayId = truncateDisplayId(
|
||||||
explicitResumeSessionDisplayId ??
|
explicitResumeSessionDisplayId ??
|
||||||
taskSessionForRun?.sessionDisplayId ??
|
taskSessionForRun?.sessionDisplayId ??
|
||||||
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
||||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||||
runtimeSessionFallback,
|
runtimeSessionFallback,
|
||||||
);
|
);
|
||||||
|
let previousSessionDisplayId = requiresCanonicalSessionIds(agent.adapterType)
|
||||||
|
? truncateDisplayId(
|
||||||
|
readNonEmptyString(previousSessionParams?.sessionId) ??
|
||||||
|
(isCanonicalSessionIdForAdapter(agent.adapterType, runtimeSessionDisplayId) ? runtimeSessionDisplayId : null) ??
|
||||||
|
runtimeSessionFallback,
|
||||||
|
)
|
||||||
|
: runtimeSessionDisplayId;
|
||||||
let runtimeSessionIdForAdapter =
|
let runtimeSessionIdForAdapter =
|
||||||
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
|
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
|
||||||
let runtimeSessionParamsForAdapter = runtimeSessionParams;
|
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({
|
const nextSessionState = resolveNextSessionState({
|
||||||
|
adapterType: agent.adapterType,
|
||||||
codec: sessionCodec,
|
codec: sessionCodec,
|
||||||
adapterResult,
|
adapterResult,
|
||||||
|
outcome,
|
||||||
previousParams: previousSessionParams,
|
previousParams: previousSessionParams,
|
||||||
previousDisplayId: runtimeForAdapter.sessionDisplayId,
|
previousDisplayId: runtimeForAdapter.sessionDisplayId,
|
||||||
previousLegacySessionId: runtimeForAdapter.sessionId,
|
previousLegacySessionId: runtimeForAdapter.sessionId,
|
||||||
@@ -8151,18 +8318,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
rawUsage,
|
rawUsage,
|
||||||
});
|
});
|
||||||
const normalizedUsage = sessionUsageResolution.normalizedUsage;
|
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 =
|
const runErrorMessage =
|
||||||
outcome === "cancelled"
|
outcome === "cancelled"
|
||||||
? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
|
? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
|
||||||
|
|||||||
Reference in New Issue
Block a user