fix(heartbeat): preserve task_session and symlink-equal cwds in inverse-migration branch

Addresses Greptile review on this PR:
- P1 (Task Sessions Lose Continuity): the inverse-of-migration branch in
  resolveRuntimeSessionParamsForWorkspace compared only against the fallback
  agent-home cwd, so a valid task_session whose resolved cwd matches the
  persisted cwd was still dropped and the next heartbeat opened a fresh
  conversation. Now short-circuits and preserves the session whenever
  canonicalizeCwdForComparison(previousCwd) === canonicalizeCwdForComparison(resolvedCwd).
- P2 (Symlinked Home Sessions Drop): all cwd equality checks in this function
  now flow through canonicalizeCwdForComparison, which prefers fs.realpathSync
  and falls back to path.resolve when the path does not exist. A fallback-home
  session persisted through a symlinked path no longer compares unequal
  against the physical fallback dir returned by the resolver.

Tests: heartbeat-workspace-session gains two new cases — task_session
preservation with a real on-disk tmp cwd, and symlink-equal fallback preservation.

Refs greptile-apps[bot] review comments on PR #9219.
This commit is contained in:
Sergio-LPA 2026-07-11 15:29:22 +00:00
parent fc200eff00
commit a3e00b9de5
2 changed files with 101 additions and 3 deletions

View File

@ -1013,6 +1013,73 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
});
expect(result.warning).toBeNull();
});
it("preserves a task_session when the resolved cwd matches the persisted cwd", async () => {
const agentId = "agent-123";
// Use a real on-disk cwd so realpath resolution succeeds; the interesting
// case is that the task_session's cwd is neither project_primary nor the
// fallback agent home. The previous implementation dropped it wholesale.
const taskSessionCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-tasksess-"));
try {
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "task-session-abc",
cwd: taskSessionCwd,
workspaceId: "workspace-9",
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: taskSessionCwd,
source: "task_session",
projectId: null,
workspaceId: "workspace-9",
}),
});
expect(result.sessionParams).toEqual({
sessionId: "task-session-abc",
cwd: taskSessionCwd,
workspaceId: "workspace-9",
});
expect(result.warning).toBeNull();
} finally {
await fs.rm(taskSessionCwd, { recursive: true, force: true });
}
});
it("preserves a fallback-home session that was persisted via a symlink to the real fallback dir", async () => {
const agentId = "agent-symlink";
const realFallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
await fs.mkdir(realFallbackCwd, { recursive: true });
const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-symlink-"));
const symlinkPath = path.join(symlinkDir, "fallback-alias");
await fs.symlink(realFallbackCwd, symlinkPath);
try {
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "session-symlink",
cwd: symlinkPath, // persisted through symlinked path
workspaceId: null,
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: realFallbackCwd, // resolver hands back the real fallback path
source: "agent_home",
projectId: null,
workspaceId: null,
}),
});
expect(result.sessionParams).toEqual({
sessionId: "session-symlink",
cwd: symlinkPath,
workspaceId: null,
});
expect(result.warning).toBeNull();
} finally {
await fs.rm(symlinkDir, { recursive: true, force: true });
}
});
});
describe("applyPersistedExecutionWorkspaceConfig", () => {

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { realpathSync } from "node:fs";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
@ -2473,6 +2474,21 @@ export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect):
return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy;
}
// Canonicalize a cwd for equality comparison across symlinked paths. The CLI
// encodes session files under the physical directory it saw, so a persisted
// cwd captured through a symlink must compare equal to the same physical
// directory expressed as its real path. Falls back to `path.resolve` when the
// path does not exist on disk (which is legitimate in unit tests and for
// prospective/planned cwds).
function canonicalizeCwdForComparison(target: string): string {
const normalized = path.resolve(target);
try {
return realpathSync(normalized);
} catch {
return normalized;
}
}
export function resolveRuntimeSessionParamsForWorkspace(input: {
agentId: string;
previousSessionParams: Record<string, unknown> | null;
@ -2487,6 +2503,8 @@ export function resolveRuntimeSessionParamsForWorkspace(input: {
warning: null as string | null,
};
}
const canonicalPreviousCwd = canonicalizeCwdForComparison(previousCwd);
const canonicalResolvedCwd = canonicalizeCwdForComparison(resolvedWorkspace.cwd);
// Inverse-of-migration case: the persisted session was captured in a
// project workspace but the current run resolves to the agent's home
// workspace (e.g. a timer/heartbeat wake with no active issue while the
@ -2497,8 +2515,20 @@ export function resolveRuntimeSessionParamsForWorkspace(input: {
// and the state stays coherent (rather than accumulating stderr warnings
// while the CLI silently self-heals).
if (resolvedWorkspace.source !== "project_primary") {
// Safe to resume when the resolved cwd matches the persisted cwd — the
// session jsonl exists under the same directory. Covers the `task_session`
// source where `resolveWorkspaceForRun` deliberately reuses the persisted
// session's cwd, and any `agent_home` resolution that lands on the exact
// same directory that hosted the session.
if (canonicalPreviousCwd === canonicalResolvedCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId);
if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) {
const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd);
if (canonicalPreviousCwd !== canonicalFallbackCwd) {
return {
sessionParams: null,
warning:
@ -2520,13 +2550,14 @@ export function resolveRuntimeSessionParamsForWorkspace(input: {
};
}
const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId);
if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) {
const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd);
if (canonicalPreviousCwd !== canonicalFallbackCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
if (path.resolve(projectCwd) === path.resolve(previousCwd)) {
if (canonicalizeCwdForComparison(projectCwd) === canonicalPreviousCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,