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", () => {
|
||||
|
||||
@@ -1420,25 +1420,46 @@ type ResumeSessionRow = {
|
||||
};
|
||||
|
||||
export function buildExplicitResumeSessionOverride(input: {
|
||||
adapterType?: string | null;
|
||||
resumeFromRunId: string;
|
||||
resumeRunSessionIdBefore: string | null;
|
||||
resumeRunSessionIdAfter: string | null;
|
||||
resumeRunSessionParams?: Record<string, unknown> | null;
|
||||
taskSession: ResumeSessionRow | null;
|
||||
sessionCodec: AdapterSessionCodec;
|
||||
}) {
|
||||
const desiredDisplayId = truncateDisplayId(
|
||||
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
|
||||
);
|
||||
const taskSessionParams = normalizeSessionParams(
|
||||
const resumeRunSessionIdAfter = truncateDisplayId(input.resumeRunSessionIdAfter);
|
||||
const resumeRunSessionIdBefore = truncateDisplayId(input.resumeRunSessionIdBefore);
|
||||
const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType)
|
||||
? isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdAfter)
|
||||
? resumeRunSessionIdAfter
|
||||
: isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdBefore)
|
||||
? resumeRunSessionIdBefore
|
||||
: null
|
||||
: resumeRunSessionIdAfter ?? resumeRunSessionIdBefore;
|
||||
const runSessionParams = requiresCanonicalSessionIds(input.adapterType)
|
||||
? normalizeResumeParamsForAdapter(
|
||||
input.adapterType,
|
||||
input.sessionCodec.deserialize(input.resumeRunSessionParams ?? null),
|
||||
)
|
||||
: null;
|
||||
const runSessionDisplayId = truncateDisplayId(readNonEmptyString(runSessionParams?.sessionId));
|
||||
const taskSessionParams = normalizeResumeParamsForAdapter(
|
||||
input.adapterType,
|
||||
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null;
|
||||
const taskSessionDisplayId = truncateDisplayId(
|
||||
input.taskSession?.sessionDisplayId ??
|
||||
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
|
||||
readNonEmptyString(taskSessionParams?.sessionId),
|
||||
requiresCanonicalSessionIds(input.adapterType)
|
||||
? readNonEmptyString(taskSessionParams?.sessionId) ??
|
||||
(isCanonicalSessionIdForAdapter(input.adapterType, taskSessionRawDisplayId) ? taskSessionRawDisplayId : null)
|
||||
: taskSessionRawDisplayId ??
|
||||
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
|
||||
readNonEmptyString(taskSessionParams?.sessionId),
|
||||
);
|
||||
const canReuseTaskSessionParams =
|
||||
input.taskSession != null &&
|
||||
(!requiresCanonicalSessionIds(input.adapterType) || taskSessionParams != null) &&
|
||||
(
|
||||
input.taskSession.lastRunId === input.resumeFromRunId ||
|
||||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
|
||||
@@ -1446,10 +1467,16 @@ export function buildExplicitResumeSessionOverride(input: {
|
||||
const sessionParams =
|
||||
canReuseTaskSessionParams
|
||||
? taskSessionParams
|
||||
: desiredDisplayId
|
||||
? { sessionId: desiredDisplayId }
|
||||
: null;
|
||||
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
|
||||
: runSessionParams
|
||||
? runSessionParams
|
||||
: desiredDisplayId
|
||||
? { sessionId: desiredDisplayId }
|
||||
: null;
|
||||
const sessionDisplayId = canReuseTaskSessionParams
|
||||
? taskSessionDisplayId
|
||||
: runSessionParams
|
||||
? runSessionDisplayId
|
||||
: desiredDisplayId;
|
||||
|
||||
if (!sessionDisplayId && !sessionParams) return null;
|
||||
return {
|
||||
@@ -2442,14 +2469,45 @@ function normalizeSessionParams(params: Record<string, unknown> | null | undefin
|
||||
return Object.keys(params).length > 0 ? params : null;
|
||||
}
|
||||
|
||||
function resolveNextSessionState(input: {
|
||||
type RunSessionOutcome = "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||
|
||||
const HERMES_ADAPTER_TYPE = "hermes_local";
|
||||
const HERMES_SESSION_ID_REGEX = /^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
|
||||
|
||||
function requiresCanonicalSessionIds(adapterType: string | null | undefined) {
|
||||
return adapterType === HERMES_ADAPTER_TYPE;
|
||||
}
|
||||
|
||||
function isCanonicalSessionIdForAdapter(
|
||||
adapterType: string | null | undefined,
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
if (!sessionId) return false;
|
||||
if (!requiresCanonicalSessionIds(adapterType)) return true;
|
||||
return HERMES_SESSION_ID_REGEX.test(sessionId);
|
||||
}
|
||||
|
||||
function normalizeResumeParamsForAdapter(
|
||||
adapterType: string | null | undefined,
|
||||
params: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const normalized = normalizeSessionParams(params);
|
||||
if (!normalized) return null;
|
||||
if (!requiresCanonicalSessionIds(adapterType)) return normalized;
|
||||
const sessionId = readNonEmptyString(normalized.sessionId);
|
||||
return isCanonicalSessionIdForAdapter(adapterType, sessionId) ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveNextSessionState(input: {
|
||||
adapterType?: string | null;
|
||||
codec: AdapterSessionCodec;
|
||||
adapterResult: AdapterExecutionResult;
|
||||
outcome: RunSessionOutcome;
|
||||
previousParams: Record<string, unknown> | null;
|
||||
previousDisplayId: string | null;
|
||||
previousLegacySessionId: string | null;
|
||||
}) {
|
||||
const { codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
|
||||
const { adapterType, codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
|
||||
|
||||
if (adapterResult.clearSession) {
|
||||
return {
|
||||
@@ -2459,38 +2517,109 @@ function resolveNextSessionState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!requiresCanonicalSessionIds(adapterType)) {
|
||||
const explicitParams = adapterResult.sessionParams;
|
||||
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
||||
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
|
||||
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
||||
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
|
||||
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
|
||||
const shouldUsePrevious = !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
|
||||
|
||||
const candidateParams =
|
||||
hasExplicitParams
|
||||
? explicitParams
|
||||
: hasExplicitSessionId
|
||||
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
|
||||
: previousParams;
|
||||
|
||||
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
|
||||
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
|
||||
|
||||
const displayId = truncateDisplayId(
|
||||
explicitDisplayId ??
|
||||
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
|
||||
readNonEmptyString(deserialized?.sessionId) ??
|
||||
(shouldUsePrevious ? previousDisplayId : null) ??
|
||||
explicitSessionId ??
|
||||
(shouldUsePrevious ? previousLegacySessionId : null),
|
||||
);
|
||||
|
||||
const legacySessionId =
|
||||
explicitSessionId ??
|
||||
readNonEmptyString(deserialized?.sessionId) ??
|
||||
displayId ??
|
||||
(shouldUsePrevious ? previousLegacySessionId : null);
|
||||
|
||||
return {
|
||||
params: serialized,
|
||||
displayId,
|
||||
legacySessionId,
|
||||
};
|
||||
}
|
||||
|
||||
const previousSerializedParams = normalizeResumeParamsForAdapter(
|
||||
adapterType,
|
||||
codec.serialize(normalizeResumeParamsForAdapter(adapterType, previousParams)),
|
||||
);
|
||||
const validPreviousDisplayId = isCanonicalSessionIdForAdapter(adapterType, previousDisplayId)
|
||||
? previousDisplayId
|
||||
: null;
|
||||
const validPreviousLegacySessionId = isCanonicalSessionIdForAdapter(adapterType, previousLegacySessionId)
|
||||
? previousLegacySessionId
|
||||
: null;
|
||||
const previousState = () => {
|
||||
const displayId = truncateDisplayId(
|
||||
readNonEmptyString(previousSerializedParams?.sessionId) ??
|
||||
validPreviousDisplayId ??
|
||||
validPreviousLegacySessionId,
|
||||
);
|
||||
return {
|
||||
params: previousSerializedParams,
|
||||
displayId,
|
||||
legacySessionId: readNonEmptyString(previousSerializedParams?.sessionId) ?? displayId ?? validPreviousLegacySessionId,
|
||||
};
|
||||
};
|
||||
|
||||
if (input.outcome !== "succeeded") {
|
||||
return previousState();
|
||||
}
|
||||
|
||||
const explicitParams = adapterResult.sessionParams;
|
||||
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
||||
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
|
||||
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
||||
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
|
||||
const validExplicitSessionId = isCanonicalSessionIdForAdapter(adapterType, explicitSessionId)
|
||||
? explicitSessionId
|
||||
: null;
|
||||
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
|
||||
const shouldUsePrevious = !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
|
||||
const validExplicitDisplayId = isCanonicalSessionIdForAdapter(adapterType, explicitDisplayId)
|
||||
? explicitDisplayId
|
||||
: null;
|
||||
const explicitSerializedParams = hasExplicitParams
|
||||
? normalizeResumeParamsForAdapter(
|
||||
adapterType,
|
||||
codec.serialize(normalizeSessionParams(explicitParams) ?? null),
|
||||
)
|
||||
: null;
|
||||
const explicitCanonicalSessionId =
|
||||
readNonEmptyString(explicitSerializedParams?.sessionId) ??
|
||||
validExplicitSessionId ??
|
||||
validExplicitDisplayId;
|
||||
|
||||
const candidateParams =
|
||||
hasExplicitParams
|
||||
? explicitParams
|
||||
: hasExplicitSessionId
|
||||
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
|
||||
: previousParams;
|
||||
if (!explicitCanonicalSessionId) {
|
||||
return previousState();
|
||||
}
|
||||
|
||||
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
|
||||
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
|
||||
|
||||
const displayId = truncateDisplayId(
|
||||
explicitDisplayId ??
|
||||
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
|
||||
readNonEmptyString(deserialized?.sessionId) ??
|
||||
(shouldUsePrevious ? previousDisplayId : null) ??
|
||||
explicitSessionId ??
|
||||
(shouldUsePrevious ? previousLegacySessionId : null),
|
||||
const serialized = normalizeResumeParamsForAdapter(
|
||||
adapterType,
|
||||
codec.serialize({ sessionId: explicitCanonicalSessionId }),
|
||||
);
|
||||
|
||||
const legacySessionId =
|
||||
explicitSessionId ??
|
||||
readNonEmptyString(deserialized?.sessionId) ??
|
||||
displayId ??
|
||||
(shouldUsePrevious ? previousLegacySessionId : null);
|
||||
const displayId = truncateDisplayId(
|
||||
readNonEmptyString(serialized?.sessionId) ??
|
||||
(codec.getDisplayId ? codec.getDisplayId(serialized) : null) ??
|
||||
explicitCanonicalSessionId,
|
||||
);
|
||||
const legacySessionId = readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId;
|
||||
|
||||
return {
|
||||
params: serialized,
|
||||
@@ -3626,6 +3755,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||
})
|
||||
@@ -3646,10 +3776,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
|
||||
: null;
|
||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
||||
const resumeRunResult = parseObject(resumeRun.resultJson);
|
||||
const resumeRunSessionId = requiresCanonicalSessionIds(agent.adapterType)
|
||||
? readNonEmptyString(resumeRunResult.sessionId) ?? readNonEmptyString(resumeRunResult.session_id)
|
||||
: null;
|
||||
const sessionOverride = buildExplicitResumeSessionOverride({
|
||||
adapterType: agent.adapterType,
|
||||
resumeFromRunId,
|
||||
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
|
||||
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
|
||||
resumeRunSessionParams: resumeRunSessionId ? { sessionId: resumeRunSessionId } : null,
|
||||
taskSession: resumeTaskSession,
|
||||
sessionCodec,
|
||||
});
|
||||
@@ -7168,7 +7304,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
||||
const sessionResetReason = describeSessionResetReason(context);
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const explicitResumeSessionParams = normalizeSessionParams(
|
||||
const explicitResumeSessionParams = normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
|
||||
);
|
||||
const explicitResumeSessionDisplayId = truncateDisplayId(
|
||||
@@ -7178,8 +7315,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
);
|
||||
const previousSessionParams =
|
||||
explicitResumeSessionParams ??
|
||||
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
|
||||
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
|
||||
(isCanonicalSessionIdForAdapter(agent.adapterType, explicitResumeSessionDisplayId)
|
||||
? { sessionId: explicitResumeSessionDisplayId }
|
||||
: null) ??
|
||||
normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||
);
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
@@ -7740,14 +7882,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||
context.projectId = executionWorkspace.projectId;
|
||||
}
|
||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
||||
let previousSessionDisplayId = truncateDisplayId(
|
||||
const runtimeSessionFallback = taskKey || resetTaskSession
|
||||
? null
|
||||
: isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
|
||||
? runtime.sessionId
|
||||
: null;
|
||||
const runtimeSessionDisplayId = truncateDisplayId(
|
||||
explicitResumeSessionDisplayId ??
|
||||
taskSessionForRun?.sessionDisplayId ??
|
||||
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback,
|
||||
);
|
||||
let previousSessionDisplayId = requiresCanonicalSessionIds(agent.adapterType)
|
||||
? truncateDisplayId(
|
||||
readNonEmptyString(previousSessionParams?.sessionId) ??
|
||||
(isCanonicalSessionIdForAdapter(agent.adapterType, runtimeSessionDisplayId) ? runtimeSessionDisplayId : null) ??
|
||||
runtimeSessionFallback,
|
||||
)
|
||||
: runtimeSessionDisplayId;
|
||||
let runtimeSessionIdForAdapter =
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
|
||||
let runtimeSessionParamsForAdapter = runtimeSessionParams;
|
||||
@@ -8136,9 +8289,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
let outcome: RunSessionOutcome;
|
||||
const latestRun = await getRun(run.id);
|
||||
if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
|
||||
outcome = latestRun.status;
|
||||
} else if (adapterResult.timedOut) {
|
||||
outcome = "timed_out";
|
||||
} else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) {
|
||||
outcome = "succeeded";
|
||||
} else {
|
||||
outcome = "failed";
|
||||
}
|
||||
|
||||
const nextSessionState = resolveNextSessionState({
|
||||
adapterType: agent.adapterType,
|
||||
codec: sessionCodec,
|
||||
adapterResult,
|
||||
outcome,
|
||||
previousParams: previousSessionParams,
|
||||
previousDisplayId: runtimeForAdapter.sessionDisplayId,
|
||||
previousLegacySessionId: runtimeForAdapter.sessionId,
|
||||
@@ -8151,18 +8318,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
rawUsage,
|
||||
});
|
||||
const normalizedUsage = sessionUsageResolution.normalizedUsage;
|
||||
|
||||
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||
const latestRun = await getRun(run.id);
|
||||
if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
|
||||
outcome = latestRun.status;
|
||||
} else if (adapterResult.timedOut) {
|
||||
outcome = "timed_out";
|
||||
} else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) {
|
||||
outcome = "succeeded";
|
||||
} else {
|
||||
outcome = "failed";
|
||||
}
|
||||
const runErrorMessage =
|
||||
outcome === "cancelled"
|
||||
? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
|
||||
|
||||
Reference in New Issue
Block a user