fix(heartbeat): guard Hermes resume session state (#7516)

## Thinking Path

> - Paperclip is a control plane for AI-agent companies
> - Heartbeats reuse adapter session state so agents can continue work
across wakeups
> - Hermes can only resume from full canonical session IDs, not
truncated display IDs
> - #6347 exposed a case where Paperclip could save invalid Hermes
output like `from`, or a shortened display ID, as resumable state
> - This pull request hardens the host-side Hermes resume path so
Paperclip only stores and reuses session IDs that can actually resume
> - The benefit is that Hermes wakeups no longer get stuck retrying bad
saved resume state

## What Changed

- Added Hermes-only validation for canonical session IDs in
`server/src/services/heartbeat.ts`.
- Stopped building Hermes resume params from truncated display IDs such
as `20260601_141558_`.
- For explicit resume-from-run wakeups, pulls the full Hermes session ID
from the run result payload after validation.
- Preserves the previous valid Hermes session state when a run fails,
times out, or is cancelled instead of replacing it with invalid adapter
output like `from`.
- Clears existing Hermes resume state that fails validation.
- Leaves non-Hermes adapter session behavior unchanged.
- Added regression coverage in
`server/src/__tests__/heartbeat-workspace-session.test.ts`.

Addresses #6347.

Supersedes #6351 and covers the full-session resume metadata handoff
from #7280.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-workspace-session.test.ts` — passed, 50 tests.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `git diff --check` — clean.

Full suite did not finish green locally; the failures were outside this
server-only heartbeat path:

- `pnpm test:run`:
  - `@paperclipai/adapter-opencode-local` remote SSH tests timed out.
- `ui/src/pages/Inbox.test.tsx` failed once in `Inbox toolbar > syncs
hover with j/k selection on inbox rows`; the direct file rerun passed.
- `ui/src/components/IssueDocumentAnnotations.test.tsx` failed once in
`auto-opens the panel and focuses the thread when deep-linked`; the
direct file rerun passed.

## Risks

- Low risk: no schema, public API, shared contract, or UI changes.
- If Hermes changes its canonical session ID format, the validation
regex will need to be updated.
- Adapter-side parsing still needs its own fix; this PR prevents
non-resumable adapter output from becoming durable Paperclip resume
state.
- This does not add an immediate same-run retry after `Session not
found`; recovery happens by clearing or preserving durable resume state
for later wakeups.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5.5 (`openai/gpt-5.5`) via opencode, with repository
read/search tools and local shell/test execution. opencode did not
expose context-window or reasoning-mode details.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used, including exact model ID and
capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally — targeted checks passed; full `pnpm
test:run` had unrelated local failures disclosed above
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A, no UI changes
- [x] I have updated relevant documentation to reflect my changes — N/A,
no user-facing docs or commands changed
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
m.seomoon
2026-06-05 14:45:25 +09:00
committed by GitHub
parent ec60da41ca
commit fb28cf38b4
2 changed files with 494 additions and 55 deletions
@@ -13,6 +13,7 @@ import {
mergeCoalescedContextSnapshot,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveNextSessionState,
resolveRuntimeSessionParamsForWorkspace,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForWake,
@@ -59,6 +60,31 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
} as unknown as typeof agents.$inferSelect;
}
const hermesSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId.trim() : null;
return sessionId ? { sessionId } : null;
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = typeof params.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
return sessionId ? { sessionId } : null;
},
getDisplayId(params: Record<string, unknown> | null) {
return typeof params?.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
},
};
const truncatingHermesSessionCodec = {
...hermesSessionCodec,
getDisplayId(params: Record<string, unknown> | null) {
const sessionId = hermesSessionCodec.getDisplayId(params);
return sessionId ? sessionId.slice(0, 16) : null;
},
};
describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
@@ -496,6 +522,264 @@ describe("buildExplicitResumeSessionOverride", () => {
},
});
});
it("does not synthesize Hermes resume params from a truncated display id", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
taskSession: {
sessionParamsJson: {
sessionId: "20260601_141000_c861e4",
},
sessionDisplayId: "20260601_141000_",
lastRunId: "run-2",
},
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toBeNull();
});
it("uses validated Hermes run result params before truncated display ids", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "20260601_141558_c861e4",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("keeps Hermes run result params and display id together when falling back from a prior session", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "20260601_140000_old123",
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "20260601_141558_c861e4",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("ignores invalid Hermes run result params", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
resumeRunSessionParams: {
sessionId: "from",
},
taskSession: null,
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toBeNull();
});
it("keeps full Hermes task-session params even when the saved display id is truncated", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: null,
resumeRunSessionIdAfter: "20260601_141558_",
taskSession: {
sessionParamsJson: {
sessionId: "20260601_141558_c861e4",
},
sessionDisplayId: "20260601_141558_",
lastRunId: "run-1",
},
sessionCodec: truncatingHermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
it("falls back from a poisoned Hermes session-after value to a valid session-before value", () => {
const result = buildExplicitResumeSessionOverride({
adapterType: "hermes_local",
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "20260601_141558_c861e4",
resumeRunSessionIdAfter: "from",
taskSession: null,
sessionCodec: hermesSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "20260601_141558_c861e4",
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
});
});
});
describe("resolveNextSessionState", () => {
it("preserves previous valid Hermes session state when failed adapter output reports prose tokens", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "from",
},
sessionId: "from",
sessionDisplayId: "from",
errorMessage: "Session not found: 20260601_141558_",
},
outcome: "failed",
previousParams: {
sessionId: "20260601_141558_c861e4",
},
previousDisplayId: "20260601_141558_c861e4",
previousLegacySessionId: "20260601_141558_c861e4",
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("drops poisoned previous Hermes session state instead of passing it to the next run", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionId: "from",
sessionDisplayId: "from",
errorMessage: "Session not found: from",
},
outcome: "failed",
previousParams: {
sessionId: "from",
},
previousDisplayId: "from",
previousLegacySessionId: "from",
});
expect(result).toEqual({
params: null,
displayId: null,
legacySessionId: null,
});
});
it("derives Hermes display state from canonical params instead of adapter-truncated display ids", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "20260601_141558_c861e4",
},
sessionDisplayId: "20260601_141558_",
},
outcome: "succeeded",
previousParams: null,
previousDisplayId: null,
previousLegacySessionId: null,
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("uses one canonical Hermes explicit session candidate instead of mixing valid and invalid fields", () => {
const result = resolveNextSessionState({
adapterType: "hermes_local",
codec: truncatingHermesSessionCodec,
adapterResult: {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: {
sessionId: "from",
},
sessionId: "20260601_141558_c861e4",
sessionDisplayId: "20260601_141558_",
},
outcome: "succeeded",
previousParams: {
sessionId: "20260601_140000_previous",
},
previousDisplayId: "20260601_140000_previous",
previousLegacySessionId: "20260601_140000_previous",
});
expect(result).toEqual({
params: {
sessionId: "20260601_141558_c861e4",
},
displayId: "20260601_141558_c861e4",
legacySessionId: "20260601_141558_c861e4",
});
});
it("keeps non-Hermes arbitrary session ids unchanged", () => {
const result = resolveNextSessionState({
adapterType: "codex_local",
codec: codexSessionCodec,
adapterResult: {
exitCode: 1,
signal: null,
timedOut: false,
sessionId: "from",
},
outcome: "failed",
previousParams: null,
previousDisplayId: null,
previousLegacySessionId: null,
});
expect(result.legacySessionId).toBe("from");
});
});
describe("formatRuntimeWorkspaceWarningLog", () => {
+210 -55
View File
@@ -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")