Skip to content

Commit 7e0e29e

Browse files
committed
fix(cron): preserve current session identity after compaction
1 parent 6d25ae5 commit 7e0e29e

5 files changed

Lines changed: 72 additions & 43 deletions

File tree

src/cron/isolated-agent.session-identity.test.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import * as modelThinkingDefault from "../agents/model-thinking-default.js";
6+
import type { SessionEntry } from "../config/sessions.js";
67
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
78
import {
89
makeCfg,
@@ -21,10 +22,13 @@ import {
2122
} from "./isolated-agent.turn-test-helpers.js";
2223
import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js";
2324
import {
25+
dispatchCronDeliveryMock,
2426
mockRunCronFallbackPassthrough,
2527
runEmbeddedPiAgentMock,
2628
updateSessionStoreMock,
2729
} from "./isolated-agent/run.test-harness.js";
30+
import { normalizeCronJobCreate } from "./normalize.js";
31+
import type { CronJob } from "./types.js";
2832

2933
setupRunCronIsolatedAgentTurnSuite();
3034

@@ -148,7 +152,7 @@ describe("runCronIsolatedAgentTurn session identity", () => {
148152
});
149153
});
150154

151-
it("persists rotated transcript identity for session-bound cron runs", async () => {
155+
it("persists rotated transcript identity for current-bound cron runs", async () => {
152156
await withTempHome(async (home) => {
153157
const deps = makeDeps();
154158
const boundSessionKey = "agent:main:telegram:direct:42";
@@ -177,34 +181,35 @@ describe("runCronIsolatedAgentTurn session identity", () => {
177181
},
178182
},
179183
});
180-
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
181-
const store = {
182-
[boundSessionKey]: {
183-
sessionId: "bound-session",
184-
sessionFile: originalSessionFile,
185-
updatedAt: Date.now(),
186-
lastInteractionAt: Date.now() - 1_000,
187-
systemSent: true,
188-
},
189-
};
184+
updateSessionStoreMock.mockImplementation(async (targetStorePath, update) => {
185+
const raw = await fs.readFile(targetStorePath, "utf-8");
186+
const store = JSON.parse(raw) as Record<string, SessionEntry>;
190187
update(store);
188+
await fs.writeFile(targetStorePath, JSON.stringify(store, null, 2), "utf-8");
191189
});
190+
const currentBoundJob = normalizeCronJobCreate(
191+
{
192+
...makeJob(DEFAULT_AGENT_TURN_PAYLOAD),
193+
sessionTarget: "current",
194+
delivery: { mode: "none" },
195+
},
196+
{ sessionContext: { sessionKey: boundSessionKey } },
197+
) as CronJob;
192198

193199
const res = await runCronIsolatedAgentTurn({
194200
cfg: makeCfg(home, storePath),
195201
deps,
196-
job: {
197-
...makeJob(DEFAULT_AGENT_TURN_PAYLOAD),
198-
sessionTarget: `session:${boundSessionKey}`,
199-
delivery: { mode: "none" },
200-
},
202+
job: currentBoundJob,
201203
message: DEFAULT_MESSAGE,
202204
sessionKey: boundSessionKey,
203205
lane: "cron",
204206
});
205207

206208
expect(res.status).toBe("ok");
207209
expect(res.sessionId).toBe("bound-session-rotated");
210+
expect(dispatchCronDeliveryMock.mock.calls.at(-1)?.[0]).toEqual(
211+
expect.objectContaining({ sessionId: "bound-session-rotated" }),
212+
);
208213

209214
const finalPersist = updateSessionStoreMock.mock.calls.at(-1);
210215
expect(finalPersist?.[0]).toBe(storePath);
@@ -218,6 +223,13 @@ describe("runCronIsolatedAgentTurn session identity", () => {
218223
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
219224
}),
220225
);
226+
227+
await expect(readSessionEntry(storePath, boundSessionKey)).resolves.toEqual(
228+
expect.objectContaining({
229+
sessionId: "bound-session-rotated",
230+
sessionFile: rotatedSessionFile,
231+
}),
232+
);
221233
});
222234
});
223235

src/cron/isolated-agent/run.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ type PreparedCronRunContext = {
459459
agentDir: string;
460460
agentSessionKey: string;
461461
runSessionId: string;
462+
currentRunSessionId: () => string;
462463
runSessionKey: string;
463464
workspaceDir: string;
464465
commandBody: string;
@@ -565,6 +566,7 @@ async function prepareCronRunContext(params: {
565566
forceNew: input.job.sessionTarget === "isolated",
566567
});
567568
const runSessionId = cronSession.sessionEntry.sessionId;
569+
const currentRunSessionId = () => cronSession.sessionEntry.sessionId ?? runSessionId;
568570
if (!cronSession.sessionEntry.sessionFile?.trim()) {
569571
cronSession.sessionEntry.sessionFile = resolveSessionTranscriptPath(runSessionId, agentId);
570572
}
@@ -582,7 +584,7 @@ async function prepareCronRunContext(params: {
582584
});
583585
const withRunSession: WithRunSession = (result) => ({
584586
...result,
585-
sessionId: cronSession.sessionEntry.sessionId ?? runSessionId,
587+
sessionId: currentRunSessionId(),
586588
sessionKey: runSessionKey,
587589
});
588590
if (!cronSession.sessionEntry.label?.trim() && baseSessionKey.startsWith("cron:")) {
@@ -819,6 +821,7 @@ async function prepareCronRunContext(params: {
819821
agentDir,
820822
agentSessionKey,
821823
runSessionId,
824+
currentRunSessionId,
822825
runSessionKey,
823826
workspaceDir,
824827
commandBody,
@@ -1040,7 +1043,7 @@ async function finalizeCronRun(params: {
10401043
agentId: prepared.agentId,
10411044
agentSessionKey: prepared.agentSessionKey,
10421045
runSessionKey: prepared.runSessionKey,
1043-
sessionId: prepared.runSessionId,
1046+
sessionId: prepared.currentRunSessionId(),
10441047
runStartedAt: execution.runStartedAt,
10451048
runEndedAt: execution.runEndedAt,
10461049
timeoutMs: prepared.timeoutMs,
@@ -1136,7 +1139,7 @@ export async function runCronIsolatedAgentTurn(params: {
11361139
params.onExecutionStarted?.({
11371140
jobId: params.job.id,
11381141
agentId: prepared.context.agentId,
1139-
sessionId: prepared.context.runSessionId,
1142+
sessionId: prepared.context.currentRunSessionId(),
11401143
sessionKey: prepared.context.runSessionKey,
11411144
phase: "runner_entered",
11421145
provider: prepared.context.liveSelection.provider,
@@ -1149,7 +1152,7 @@ export async function runCronIsolatedAgentTurn(params: {
11491152
params.onExecutionPhase?.({
11501153
jobId: params.job.id,
11511154
agentId: prepared.context.agentId,
1152-
sessionId: prepared.context.runSessionId,
1155+
sessionId: prepared.context.currentRunSessionId(),
11531156
sessionKey: prepared.context.runSessionKey,
11541157
provider: prepared.context.liveSelection.provider,
11551158
model: prepared.context.liveSelection.model,

src/cron/normalize.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
} from "./delivery-field-schemas.js";
1414
import { parseAbsoluteTimeMs } from "./parse.js";
1515
import { inferLegacyName } from "./service/normalize.js";
16-
import { assertSafeCronSessionTargetId } from "./session-target.js";
16+
import {
17+
assertSafeCronSessionTargetId,
18+
resolveCronCurrentSessionTarget,
19+
} from "./session-target.js";
1720
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "./stagger.js";
1821
import type { CronJobCreate, CronJobPatch } from "./types.js";
1922

@@ -581,28 +584,14 @@ export function normalizeCronJobInput(
581584
}
582585
}
583586

584-
// Resolve "current" sessionTarget to the actual sessionKey from context
585-
if (next.sessionTarget === "current") {
586-
if (options.sessionContext?.sessionKey) {
587-
const sessionKey = options.sessionContext.sessionKey.trim();
588-
if (sessionKey) {
589-
// Store as session:customId format for persistence
590-
next.sessionTarget = `session:${assertSafeCronSessionTargetId(sessionKey)}`;
591-
}
592-
}
593-
// If "current" wasn't resolved, fall back to "isolated" behavior
594-
// This handles CLI/headless usage where no session context exists
595-
if (next.sessionTarget === "current") {
596-
next.sessionTarget = "isolated";
597-
}
598-
}
599-
if (next.sessionTarget === "current") {
600-
const sessionKey = options.sessionContext?.sessionKey?.trim();
601-
if (sessionKey) {
602-
next.sessionTarget = `session:${assertSafeCronSessionTargetId(sessionKey)}`;
603-
} else {
604-
next.sessionTarget = "isolated";
605-
}
587+
const resolvedSessionTarget = resolveCronCurrentSessionTarget({
588+
sessionTarget: typeof next.sessionTarget === "string" ? next.sessionTarget : undefined,
589+
sessionKey: options.sessionContext?.sessionKey,
590+
});
591+
if (resolvedSessionTarget !== undefined) {
592+
next.sessionTarget = resolvedSessionTarget;
593+
} else {
594+
delete next.sessionTarget;
606595
}
607596
if (
608597
"schedule" in next &&

src/cron/session-target.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
resolveCronCurrentSessionTarget,
34
resolveCronDeliverySessionKey,
45
resolveCronFailureNotificationSessionKey,
56
resolveCronNotificationSessionKey,
@@ -19,6 +20,19 @@ describe("cron session target helpers", () => {
1920
);
2021
});
2122

23+
it("resolves current targets to the creator session key", () => {
24+
expect(
25+
resolveCronCurrentSessionTarget({
26+
sessionTarget: "current",
27+
sessionKey: " agent:main:telegram:direct:123 ",
28+
}),
29+
).toBe("session:agent:main:telegram:direct:123");
30+
});
31+
32+
it("falls back current targets to isolated without a creator session key", () => {
33+
expect(resolveCronCurrentSessionTarget({ sessionTarget: "current" })).toBe("isolated");
34+
});
35+
2236
it("prefers sessionTarget over creator sessionKey for delivery", () => {
2337
expect(
2438
resolveCronDeliverySessionKey({

src/cron/session-target.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ export function resolveCronSessionTargetSessionKey(
2424
return assertSafeCronSessionTargetId(sessionTarget.slice(8));
2525
}
2626

27+
export function resolveCronCurrentSessionTarget(params: {
28+
sessionTarget?: string | null;
29+
sessionKey?: string | null;
30+
}): string | undefined {
31+
if (params.sessionTarget !== "current") {
32+
return params.sessionTarget ?? undefined;
33+
}
34+
const sessionKey = params.sessionKey?.trim();
35+
return sessionKey ? `session:${assertSafeCronSessionTargetId(sessionKey)}` : "isolated";
36+
}
37+
2738
export function resolveCronDeliverySessionKey(job: {
2839
sessionTarget?: string | null;
2940
sessionKey?: string | null;

0 commit comments

Comments
 (0)