Fix heartbeat task-session reuse when agent model changes (#4195)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Heartbeats wake agents and resume prior adapter task sessions so work is continuous. > - A persisted task session can contain adapter-specific state (for Codex, a resumable thread/session) created under the agent's then-current model. > - When an operator changes an agent's configured model, the next run should not blindly reuse a session created under a different model — context window, capabilities, and prompt assumptions may differ. > - The existing wake reset logic handles wake reasons (forceFreshSession, comment wakes, etc.) but not model drift between current agent config and persisted task-session metadata. > - This pull request adds model-aware task-session reset and persists the configured model into task-session metadata. > - The benefit is that heartbeat runs reliably honor the current agent model configuration and avoid stale session/model mismatches. ## Linked Issues or Issue Description **What happened?** After an operator changes an agent's configured model (for example, swapping a Codex agent from one model variant to another), the heartbeat reuses the persisted adapter task session that was created under the previous model. The new model never takes effect on resume — the run continues on the prior session and prior model assumptions. **Expected behavior** A model change in agent configuration should invalidate the persisted task session for that agent and force a fresh session start on the next run, so the configured model is the one actually used. **Steps to reproduce** 1. Run an agent with model `A` so it persists an adapter task session under model `A`. 2. Change the agent's configured model to `B`. 3. Trigger a heartbeat for the same issue/agent. 4. Observe: the run resumes the prior task session (still under model `A`) instead of starting fresh under model `B`. ## What Changed - Added task-session model metadata support in heartbeat session handling via `__paperclipConfiguredModel`. - Persisted the current configured adapter model into `agent_task_sessions.sessionParamsJson` whenever heartbeat upserts task-session state. - Added `shouldResetTaskSessionForModelChange(...)` to explicitly detect model drift between current config and persisted session metadata. - Updated run startup logic to force a fresh session when model drift is detected, with a clear reason message in runtime warnings. - Strips the internal `__paperclipConfiguredModel` key from `sessionParamsJson` before it is forwarded to adapters so the metadata stays internal. - Added focused tests in `server/src/__tests__/heartbeat-workspace-session.test.ts` covering model-drift reset behavior, non-reset cases, and the strip helper. ## Verification - `pnpm --filter @paperclipai/server test src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/server typecheck` ## Risks Low. Sessions without persisted model metadata are not reset (backward compatible). The model key is namespaced (`__paperclip...`) to avoid colliding with adapter-forwarded params. Drift detection only fires when both current config and persisted metadata are present and differ. ## Model Used Claude (Opus 4.6) — used to design the metadata persistence, add the drift detection helper, and write unit coverage. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [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 - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (in progress) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
@@ -21,6 +21,9 @@ import {
|
||||
shouldDeferFollowupWakeForSameIssue,
|
||||
stripHostWorkspaceProvisionForLowTrustSandbox,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
shouldResetTaskSessionForModelChange,
|
||||
stripConfiguredModelFromSessionParams,
|
||||
normalizeSessionParams,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
@@ -926,6 +929,113 @@ describe("shouldDeferFollowupWakeForSameIssue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetTaskSessionForModelChange", () => {
|
||||
it("resets when configured model differs from persisted session model", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForModelChange({
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: {
|
||||
sessionId: "thread-1",
|
||||
__paperclipConfiguredModel: "opencode/mimo-v2-pro-free",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reset when models match", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForModelChange({
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: {
|
||||
sessionId: "thread-1",
|
||||
__paperclipConfiguredModel: "gpt-5.4-mini",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset when persisted session model is missing", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForModelChange({
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: {
|
||||
sessionId: "thread-1",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset when configured model is missing", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForModelChange({
|
||||
configuredModel: null,
|
||||
taskSessionParams: {
|
||||
sessionId: "thread-1",
|
||||
__paperclipConfiguredModel: "gpt-5.4-mini",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset when task session params are missing", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForModelChange({
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripConfiguredModelFromSessionParams", () => {
|
||||
it("removes the internal model key from persisted session params", () => {
|
||||
expect(
|
||||
stripConfiguredModelFromSessionParams({
|
||||
sessionId: "thread-1",
|
||||
__paperclipConfiguredModel: "gpt-5.4-mini",
|
||||
}),
|
||||
).toEqual({ sessionId: "thread-1" });
|
||||
});
|
||||
|
||||
it("returns null when session params are missing", () => {
|
||||
expect(stripConfiguredModelFromSessionParams(null)).toBeNull();
|
||||
expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a copy without mutating the input", () => {
|
||||
const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" };
|
||||
const result = stripConfiguredModelFromSessionParams(input);
|
||||
expect(result).not.toBe(input);
|
||||
expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("returns an empty object when only the internal model key is present (caller must normalize)", () => {
|
||||
const stripped = stripConfiguredModelFromSessionParams({
|
||||
__paperclipConfiguredModel: "gpt-5.4-mini",
|
||||
});
|
||||
expect(stripped).toEqual({});
|
||||
// Callers that forward params to adapters must normalize {} back to null so
|
||||
// the pre-PR null contract is preserved (adapters distinguishing {} from null).
|
||||
expect(normalizeSessionParams(stripped)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSessionParams", () => {
|
||||
it("collapses an empty object to null", () => {
|
||||
expect(normalizeSessionParams({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null or undefined inputs", () => {
|
||||
expect(normalizeSessionParams(null)).toBeNull();
|
||||
expect(normalizeSessionParams(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a non-empty object", () => {
|
||||
const params = { sessionId: "thread-1" };
|
||||
expect(normalizeSessionParams(params)).toBe(params);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTaskKeyWithHeartbeatFallback", () => {
|
||||
it("returns explicit taskKey when present", () => {
|
||||
expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123");
|
||||
|
||||
@@ -2131,6 +2131,49 @@ export function shouldDeferFollowupWakeForSameIssue(input: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel";
|
||||
|
||||
function readConfiguredModelFromAdapterConfig(
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
return readNonEmptyString(adapterConfig?.model);
|
||||
}
|
||||
|
||||
function attachConfiguredModelToSessionParams(
|
||||
sessionParams: Record<string, unknown> | null | undefined,
|
||||
configuredModel: string | null,
|
||||
) {
|
||||
if (!configuredModel) return sessionParams ?? null;
|
||||
const next = { ...(sessionParams ?? {}) };
|
||||
next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel;
|
||||
return next;
|
||||
}
|
||||
|
||||
function readConfiguredModelFromSessionParams(
|
||||
sessionParams: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]);
|
||||
}
|
||||
|
||||
export function shouldResetTaskSessionForModelChange(input: {
|
||||
configuredModel: string | null;
|
||||
taskSessionParams: Record<string, unknown> | null | undefined;
|
||||
}) {
|
||||
const { configuredModel, taskSessionParams } = input;
|
||||
if (!configuredModel || !taskSessionParams) return false;
|
||||
const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams);
|
||||
return !!sessionModel && sessionModel !== configuredModel;
|
||||
}
|
||||
|
||||
export function stripConfiguredModelFromSessionParams(
|
||||
sessionParams: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (!sessionParams) return null;
|
||||
const next = { ...sessionParams };
|
||||
delete next[SESSION_CONFIGURED_MODEL_KEY];
|
||||
return next;
|
||||
}
|
||||
|
||||
function shouldAutoCheckoutIssueForWake(input: {
|
||||
contextSnapshot: Record<string, unknown> | null | undefined;
|
||||
issueStatus: string | null;
|
||||
@@ -2846,7 +2889,7 @@ function getAdapterSessionCodec(adapterType: string) {
|
||||
return adapter.sessionCodec ?? defaultSessionCodec;
|
||||
}
|
||||
|
||||
function normalizeSessionParams(params: Record<string, unknown> | null | undefined) {
|
||||
export function normalizeSessionParams(params: Record<string, unknown> | null | undefined) {
|
||||
if (!params) return null;
|
||||
return Object.keys(params).length > 0 ? params : null;
|
||||
}
|
||||
@@ -7833,11 +7876,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const configuredModel = readConfiguredModelFromAdapterConfig(config);
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
: null;
|
||||
const resetTaskSession = shouldResetTaskSessionForWake(context);
|
||||
const sessionResetReason = describeSessionResetReason(context);
|
||||
const taskSessionDecodedParams = normalizeSessionParams(
|
||||
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({
|
||||
configuredModel,
|
||||
taskSessionParams: taskSessionDecodedParams,
|
||||
});
|
||||
const resetTaskSession = shouldResetTaskSessionForWake(context) || modelChangedSinceTaskSession;
|
||||
const wakeSessionResetReason = describeSessionResetReason(context);
|
||||
const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(taskSessionDecodedParams);
|
||||
const modelSessionResetReason = modelChangedSinceTaskSession && taskSessionConfiguredModel
|
||||
? `configured model changed from "${taskSessionConfiguredModel}" to "${configuredModel}"`
|
||||
: null;
|
||||
const sessionResetReason = [modelSessionResetReason, wakeSessionResetReason]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("; ") || null;
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const explicitResumeSessionParams = normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
@@ -7855,9 +7914,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: null) ??
|
||||
normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||
stripConfiguredModelFromSessionParams(
|
||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||
),
|
||||
);
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
@@ -8490,7 +8550,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: runtimeSessionDisplayId;
|
||||
let runtimeSessionIdForAdapter =
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
|
||||
let runtimeSessionParamsForAdapter = runtimeSessionParams;
|
||||
let runtimeSessionParamsForAdapter = normalizeSessionParams(
|
||||
stripConfiguredModelFromSessionParams(runtimeSessionParams),
|
||||
);
|
||||
|
||||
const sessionCompaction = await evaluateSessionCompaction({
|
||||
agent,
|
||||
@@ -9159,7 +9221,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
taskKey,
|
||||
sessionParamsJson: nextSessionState.params,
|
||||
sessionParamsJson: attachConfiguredModelToSessionParams(nextSessionState.params, configuredModel),
|
||||
sessionDisplayId: nextSessionState.displayId,
|
||||
lastRunId: finalizedRun.id,
|
||||
lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"),
|
||||
@@ -9242,7 +9304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
taskKey,
|
||||
sessionParamsJson: previousSessionParams,
|
||||
sessionParamsJson: attachConfiguredModelToSessionParams(previousSessionParams, configuredModel),
|
||||
sessionDisplayId: previousSessionDisplayId,
|
||||
lastRunId: failedRun.id,
|
||||
lastError: message,
|
||||
|
||||
Reference in New Issue
Block a user